Привет, Хабр! Меня зовут Павел Агалецкий, я ведущий инженер в платформе Авито. Эта статья на одну из самых холиварных тем, о которой вы могли слышать или читать множество раз. При обсуждении Go, особенно новичками или представителями других языков программирования, камнем преткновения обычно становится проверка ошибок — if err != nil.

Рассказываю, какие есть особенности и нюансы, сравниваю обработку ошибок в Go и других языках. Говорим о подходах к изменению обработки ошибок и обсуждаем последний proposal от Яна Тейлора. А еще разбираемся, почему все предложения отклонялись.

Эта тема важна для Авито, потому что у нас на Go написано 2000+ сервисов. Мы постоянно в них пишем эти if err != nil. Их миллионы в нашей кодовой базе. Go — один из основных языков, на котором мы разрабатываем внутреннюю платформу для написания кода. В Авито насчитывается несколько сотен Go-разработчиков, которые интересуются этим языком и регулярно обсуждают, как лучше писать код на Go.

Эта статья написана по мотивам моего выступления на конференции Golang Conf 2025. Посмотреть запись выступления можно по ссылке.

Содержание:

Обработка ошибок в Go

Обработка ошибок в Golang — одна из самых холиварных тем. 

Посмотрим на самый примитивный код на Go:

func doSomething() {
    one()
    two()
    three()
}

В этом примере метод doSomething внутри себя вызывает несколько других методов. В таком виде мы сразу понимаем, что, скорее всего, методы one, two и three не возвращают ошибок и метод doSomething тоже никакой ошибки не возвращает.

Но что если мы хотим, чтоб метод doSomething всё-таки её вернул?

func doSomething() error {
}

Но с самого начала появления Go ошибки — это просто значение. Об этом есть даже статья Роба Пайка.

// The error built-in interface type
// is the conventional interface
// for representing an error condition,
// with the nil value representing no error.
type error interface {
    Error() string
}

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

type MyErr string
func (e MyErr) Error() string {
return string(e)
}

Нет специальных методов, ключевых слов или подходов к тому, чтобы обособить ошибки каким-либо образом.

err := one()
if err != nil {
    return err
}

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

Чаще всего при обработке ошибки мы получаем её из функции, проверяем, nil она или нет. Если не nil, то что-то делаем дальше. Обычно выходим из функции, передавая ошибку далее. Этот подход — вызвать метод, получить из него ошибку, проверить её — можно чуть сократить, написать вызов функции и проверку в одну строчку, немного изменив семантику. В этом случае ошибка, возвращаемая из метода, будет доступна только внутри блока if.

if err := one(); err != nil {
    return err
}

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

func doSomething() error {
    if err := one(); err != nil {
        return err
}
    if err := two(); err != nil {
        return err
}
    if err := three(); err != nil {
        return err
}
    return nil
}

Код испещрён бесконечным количеством конструкций if err != nil. Дальше с ними что-то нужно сделать — в большинстве случаев возвращаем их из нашей функции.

А что в других языках? 

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

def doSomething():
    one()
    two()
    three()

Из такого синтаксиса невозможно понять сходу, а бросаются или нет здесь какие-то исключения, потому что в Python именно исключения являются способом возврата ошибок из того или иного метода. Мы ничего здесь не видим. Может быть, метод one сейчас не выкидывает exceptions, а начнёт это делать в следующем релизе. Мы про это ничего не можем сказать.

Но если мы хотим обрабатывать интересующие исключения, в Python есть конструкция try/catch, которую можно найти и во множестве других языков.

def doSomething():
    try:
        one()
        two()
        three()
    except MyException as e:
        print(e)

Мы можем интересоваться определённым исключением, реагировать на него, а все остальные в этом случае для нас не будут существовать. Хотя на самом деле могут выбрасываться.

Сравним это с Go. Это приблизительно эквивалентный код.

Python:

def doSomething():
    one()
    two()
    three()

Go:

func doSomething() error {
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
         return err
    }
    if err := three(); err != nil {
        return err
    }
      return nil
}

В случае Go мы вынуждены явно обрабатывать все возвращаемые ошибки, а на Python мы ничего не обязаны делать. Но ценой того, что мы не знаем, есть ли ошибка в этом методе. Если мы, например, писали его, держа в голове, что ни одна из вызываемых нами функций не выбрасывала ошибки, а потом это происходит, то поведение нашего кода может внезапно измениться. В Python мы предполагаем, что тот, кто вызывает нашу функцию (фреймворк, библиотека, ещё что-то), за нас побеспокоится о том, чтобы обрабатывать эти исключения.

В Go такого нет. Мы вынуждены явно принять каждую ошибку и по каждой сделать решение — выбрасывать её дальше или оставлять внутри метода, может быть, как-то её обрабатывать.

В Java ситуация с этим похожа на Python.

public static void main(String[] args) {
    one();
    two();
    three();
}

Мы тоже можем написать какой-нибудь метод, который вызывает другие методы. По умолчанию в ванильном виде мы не можем сказать, есть ли исключения. Нам ничего сходу неизвестно.

Но в Java есть подход checked exceptions:

public static void main(String[] args) {
    one();
    two();
    three();
}
private static void one() throws Exception {
    throw new Exception("ooops");
}

Иными словами, это инициатива в языке, которая появилась достаточно давно и позволила прямо в сигнатуру метода зашить указание о том, что этот метод выбрасывает исключение. Если он написан подобным образом, то, вызывая его, мы должны либо добавить в вызывающий метод указание на то, что он будет пробрасывать данное исключение далее:

public static void main(String[] args) throws Exception {
    one();
    two();
    three();
}

Либо мы должны явно его обработать:

public static void main(String[] args) {
    try {
        one();
        two();
        three();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

Java даёт нам подход к тому, чтобы мы могли прямо в сигнатуру нашей функции или метода зашить информирование о том, что эта функция выбрасывает исключения.

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

И в этом есть существенный плюс. Если вы описываете, что ваша функция выбрасывает исключение, а потом меняете этот список или добавляете ещё какое-то исключение к перечню тех, которые ваш метод бросает, то это повлияет на всех, кто вашу функцию вызывает. Иными словами, это способ изменить сигнатуру функции, что, естественно, является обратно несовместимым.

В ванильном виде, когда никто не описывает никаких исключений, это выглядит примерно как в Python:

Мы смотрим на код Java и не видим, бросаются ли в вызываемых методах исключения или не бросаются. В Go ничего не поменялось — мы по-прежнему видим, что какие-то функции вполне себе возвращают ошибки.

Посмотрим на чуть более молодого конкурента для языка Go — на Rust. В нём есть подход к обработке ошибок: специальный механизм, позволяющий вернуть особый тип result. Он говорит о том, что функция возвращает одно из двух значений. 

fn do_something() -> Result<(), Error> { 
    one()?;
    two()?;
    three()?;
    return Ok(());
}

В данном случае функция либо ничего не возвращает, либо возвращает ошибку. Из сигнатуры метода мы явно видим, что этот метод может вернуть ошибку. При этом при вызове функций внутри кода на Rust мы можем обработать все возвращаемые ими ошибки, используя специальный символ в виде знака вопроса, по сути дела, синтетический сахар. Знак вопроса указывает на то, что мы вызываем эту функцию и если она возвращает result, одним из типов которого является ошибка, то она должна быть проброшена дальше. В данном случае ошибка должна вернуться из метода do_something.

Сравним с языком Go:

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

В Go ошибки — это просто значения, они никак не выделены. Если наша функция возвращает какое-то значение и ошибку, ничто нас не заставляет вернуть либо одно, либо другое. Мы можем спокойно из этой функции возвращать и ошибку, и значение, или даже это скомпоновать. Это просто возвращаемые значения функции. 

В Rust есть тип result, и мы вынуждены верн��ть либо ошибку, либо обычное значение. Естественно, в Go можно попробовать пойти таким же путём, придумать собственный тип result и возвращать его. Но это, скорее всего, будет не очень удобно, когда мы дальше будем писать код. Потому что никакой синтаксической поддержки в Go, в отличие от Rust, для такого способа нет. В Rust это получается чуть более компактно.

Тут еще больше контента

Польза подхода Go-way

Какая польза есть от подхода в Go, когда мы просто возвращаем ошибки? 

Ещё раз посмотрим на код:

func doSomething() error {
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
        return err
    }
    if err := three(); err != nil {
        return err
    }
        return nil
    }

Мы вызываем методы, проверяем, вернули ли они ошибки. Если да, то возвращаем их дальше. В таком виде код выглядит достаточно вербозно. Кажется, зачем это всё нужно?

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

func doSomething() error {
    if err := one(); err != nil {
      return fmt.Errorf("one failed: %w", err)
    }
    if err := two(); err != nil {
       return fmt.Errorf("two failed: %w", err)
    }
    if err := three(); err != nil {
        return fmt.Errorf("three failed: %w", err)
    }
    return nil
}

Здесь мы пишем, что ошибку вернул метод one, метод two и так далее. Это уже даёт дополнительную информацию тому, кто будет вызывать наш метод.

Например, если мы логируем ошибки, записываем их в какую-то систему сбора, то даже просто по текстовому описанию можем понять, где именно в приложении засбоило. Получается простой stack trace, очень дешёвый в плане его имплементации. Он не требует больших вычислительных ресурсов, несложный с точки зрения имплементации кода, но очень полезный. Мы понимаем, где произошла ошибка, и это нам поможет в отладке.

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

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

var ErrValidation = errors.New("validation error")
func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return fmt.Errorf("%w: %w", ErrValidation, err)
    }
    // ...
    return nil
}

В этом случае мы можем завести специальную переменную, назвать её ErrValidation и присвоить в неё экземпляр ошибки. Если у нас есть входная Dto и метод validate для неё, все ошибки, которые этот метод возвращает, можно снабдить дополнительной меткой про ErrValidation. Тогда код, вызывающий наш мето, сможет понять, что именно эта ошибка относится к классу ошибок в валидации и каким-то образом обработать её отдельно. Если ему это не надо, то для него это будет просто обычная ошибка, которую он может обрабатывать, как и все остальные.

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

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

type DetailsError struct {
    Method string
}
func (e *DetailsError) Error() string {
    return "problem in " + e.Method
}
func doSomething(in Dto) error {
    if err := one(); err != nil {
        return fmt.Errorf("%w: %w", &DetailsError{Method: "one"}, err)
    }
    /* ... */
    return nil
}

Мы можем сделать соответствующую структуру, у которой в одном из полей находится название метода. Она будет имплементировать интерфейс ошибки, и тогда мы можем, вызывая какие-то функции внутри метода doSomething, снабдить их этой дополнительной ошибкой, содержащей нужные нам детали.

Иногда мы хотим вернуть несколько ошибок сразу. Например, у нас есть метод validate, проверяющий входную модель. В ней есть два поля — допустим, имя (Name) и email (Email). Если они пустые, то мы хотим вернуть указывающую на соответствующую проблему ошибку.

Мы можем это сделать прямо в лоб — то есть просто последовательно проверяем, что если Name пустой, то возвращаем соответствующую информацию (Name пустой), если Email пустой, — возвратим его. Но это не очень дружелюбно по отношению к пользователю. Ведь если кто-то вызывает наш метод, то потом он внезапно может обнаружить, что передал пустое имя, исправляет это, передаёт заново, и снова получает ошибку — «email пустой». Было бы классно наоборот, вернуть сразу полную информацию о том, что же не так с его входными параметрами.

Сделать это несложно. Достаточно использовать механизм errors.Join, который использует стандартную функцию из stdlib в Go.

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
       return fmt.Errorf("%w: %w", ErrValidation, err)
    }
    /* ... */
    return nil
}
func validate(in Dto) error {
    var err error
    if in.Name == "" {
    err = errors.Join(err, errors.New("name is empty"))
    }
    if in.Email == "" {
        err = errors.Join(err, errors.New("email is empty"))
    }
    return err
}

Тогда мы можем просто объединить несколько ошибок вместе, получить одну общую итоговую и также её возвращать. Интереснее всего, что тот, кому неважно, какие ошибки находятся внутри, будет работать с результирующей ошибкой просто как есть. Это будет для него самая обычная ошибка. Если же он хочет, например, каким-то образом разобрать, какие баги находятся внутри, то может использовать стандартные функции языка Go, делать unwrapping ошибки и добраться до того, что упаковано внутри. Таким образом, сигнатура нашего метода остаётся одной и той же — мы всегда просто возвращаем ошибку. Тот, кому не важно, что внутри, оставляет её как есть, а тот, кому важно, может с ней каким-то образом работать.

Естественно, всё это полезно тогда, когда мы ошибки собираемся обрабатывать. Допустим, у нас есть функция main, которая вызывает метод doSomething. Если есть какая-то ошибка, то она просто логирует всё это через Fatal, который, как все мы знаем, завершает приложение и выводит ошибку в лог. 

func main() {
    in := Dto{}
    err := doSomething(in)
    if err != nil {
        log.Fatal(err)
    }
}

Допустим, мы хотим эту ошибку в валидации обработать каким-то особым образом. Мы это можем сделать, используя функцию errors.Is, которая проверит, что внутри переданной в неё ошибки содержится маркер ErrValidation).

func main() {
    in := Dto{}
    err := doSomething(in)
    if errors.Is(err, ErrValidation) {
        log.Fatal("validation failed: %v", err.Error())
    }
    if err != nil {
       log.Fatal(err)
    }
}

Мы можем добраться до деталей обрабатываемой ошибки, тех самых detailsErr, используя метод errors.As, и вытянуть дополнительный контекст. 

func main() {
    in := Dto{}
    err := doSomething(in)
    var detailsErr *DetailsError
    if errors.As(err, &detailsErr) {
        log.Fatal("error in method: %s", detailsErr.Method)
    }
    if err != nil {
       log.Fatal(err)
     }
}

Подобный подход довольно часто применяется, в том числе, в стандартной библиотеке. Например, когда мы делаем какой-нибудь сетевой запрос, то штатный, стандартный клиент http может в случае ошибки вернуть её как просто error, и мы можем с ней что-то сделать, например, просто залогировать. Но если мы хотим получить какие-нибудь детали, допустим, узнать, а не случился ли тайм-аут запроса, то как раз в стандартной библиотеке подобный подход используется. То есть ошибки, возвращаемые клиентам, могут имплементировать определённые структуры или интерфейсы, и мы можем добраться до внутрянки ошибки, получить о ней дополнительную информацию, если нам это нужно.

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

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return fmt.Errorf("%w: %w", ErrValidation, err)
    }
    if err := one(); err != nil {
       return fmt.Errorf("%w: %w", &DetailsError{Method: "one"}, err)
    }
    if err := two(); err != nil {
        return fmt.Errorf("%w: %w", &DetailsError{Method: "two"}, err)
    }
    if err := three(); err != nil {
        return fmt.Errorf("%w: %w", &DetailsError{Method: "three"}, err)
    }
   return nil
}

Получаем 3-4 полезных строчки кода из 12 — они просто все ошибки обрабатывают и ничего дополнительного нашему коду не добавляют.

Жми сюда!

И всё-таки – вербозно! Какие были попытки изменить обработку ошибок

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

За время существования языка было сделано довольно много proposal (195 закрытых, 14 открытых), что указывает на то, что многие хотят что-то поменять и улучшить в отношении обработки ошибок.

Естественно, рассмотреть все эти предложения невозможно просто по времени, но их можно сгруппировать:

  • Добавить методы вида check/handle. Это специальные конструкции, которые позволят обрабатывать ошибки единообразно и на них реагировать.

  • Добавить try/catch. Это похоже на check/handle, но не требует обязательно обрабатывать ошибку там же, где она возникает. 

  • Добавить специальные символы. По сути, это объединяет два первых варианта, но при этом не придумывает методы check/handle, а снабжает их какими-нибудь специальными символами. Обычно предлагают использовать знак вопроса или восклицательный знак.

  • Попытки упростить if. В этой группе предлагается не делать что-то для ошибок, но улучшить if, которые мы пишем, сделать их более компактными.

Добавление check/handle

Первая группа предложений состоит в следующем. Есть некий код на Go, где мы обрабатываем ошибки.

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return err
    }
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
        return err
    }
    if err := three(); err != nil {
        return err
    }
    return nil
}

Появляется check/handle, то есть каждую вызываемую функцию, мы проверяем специальным ключевым словом check. Само слово может быть другое, в разных proposal они называются по-разному, но смысл остаётся тот же.

func doSomething(in Dto) error {
    handle err { return err }
    check validate(in)
    check one()
    check two()
    check three()
     return nil
}

Check принимает в себя функцию, которая имплементирует некую сигнатуру. В этой сигнатуре обязательно должен быть возврат ошибки. Если функция вернула не nil ошибку, то вызывается метод handle (тоже специальное ключевое слово), где эту ошибку мы уже можем каким-то образом обработать.

При этом check работает даже в выражениях:

func doSomething(in Dto) error {
    handle err { return err }
    check validate(in)
    return check one() + check two()
}

Мы можем предварить им вызов метода one и метода two, если он помимо ошибки возвращает ещё какое-то значение. Тогда мы можем написать просто check one() + check two() и, если какой-то из них выкинет ошибку, прервем выполнение и снова пойдём в метод handle.

Подобные предложения чаще всего отклоняют по нескольким причинам:

  • Handle очень похож на defer 

Defer тоже вызывается в случае, когда метод завершает свою работу и во всех кейсах. Handle тоже вызывается в конце работы мето��а и по принципу действия становится похож на то, как работает defer.

  • Ломается на ошибках, которые оборачиваются

Если мы хотим каким-то образом обрабатывать ошибки, например, чтобы именно у этого метода ошибка была каким-то образом обёрнута, с ней что-то произошло, то использование общего handle этому мешает, потому что мы не знаем точно, какой из методов ниже ошибку выбросил. Соответственно, обработать её особым образом будет довольно сложно.

  • Самое главное, что любой желающий может реализовать эту историю у себя просто в userland.

Например, мы можем сделать метод handle, который будет работать на основе recover panic.

type PanicErr error
func handle(err *error, onErr func(err error) error) {
    rErr := recover()
    if pErr, ok := rErr.(PanicErr); ok { 
        *err = onErr(pErr)
        return
    }
    if rErr != nil {
        panic(rErr)
    }
}
func check(err error) {
    if err != nil {
        panic(PanicErr(err))
    }
}

Мы будем проверять, что panic, которая была выброшена, на самом деле имеет определённый тип. Если это так, то мы просто эту panic переложим в ошибку, которая передается в метод handle одним из параметров. В свою очередь метод check будет принимать на вход некую ошибку. Если она не пустая, то метод будет просто паниковать.

С внедрением дженериков мы можем сделать вариации check для случая, когда метод возвращает не просто  ошибку, а ещё в дополнение к этому какое-то значение.

func check2[T any](v T, err error) T {
    if err != nil {
        panic(PanicErr(err))
    }
    return v
}

Здесь мы используем panic для методов, которые возвращают одно значение и ошибку. Если ошибка была, мы паникуем; если не было — просто возвращаем из метода исходное значение.

Выглядит примерно так: мы используем метод check, оборачивая в него все функции, которые хотим упростить. Общий метод handle лежит внутри defer и скармливает полученную ошибку, параметру err, который является частью сигнатуры исходной функции doSomething.

func doSomething(in Dto) (err error) {
    defer handle(&err, func(err error) error {
        return err
    })
    check(validate(in))
    check(one())
    check(two())
    check(three())
    return nil
}

Это очень похоже на то, как если бы в язык добавили подобную конструкцию нативно.

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

Добавление try

Следующий подход стоит в добавлении try. 

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return err
    }
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
        return err
    }
    if err := three(); err != nil {
        return err
    }
    return nil
}

Основное отличие от check/handle в том, что в случае check/handle мы должны вызывать handle там же, где вызывали метод check, а в случае try мы этого делать не обязаны.

func doSomething(in Dto) (err error) {
    try(validate(in))
    try(one())
    try(two())
    try(three())
    return nil
}

Обычно try предлагает добавить функцию, которая каким-то образом будет понимать, что вызывается внутри метода doSomething, который тоже в своей сигнатуре возвращает ошибку. Если это так, то ошибку, возвращаемую вызываемыми функциями, он пробросит выше, тем самым делая неявный выход из функции. То есть, по сути, метод try в некотором выражении делает неявный return.

При желании мы можем реализовать этот подход самостоятельно. Можно написать его в виде методов внутри userland. При желании можно добавить defer, чтобы проверять значение err и враппить его.

func doSomething(in Dto) (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("%w: %w", ErrValidation, err)
        }
}
    try(validate(in))
    try(one())
    try(two())
    try(three())
    return nil
}

Почему отклоняют? 

  • Flow control начинает работать на уровне expression

flow control, то есть выполнение функции, возможность вернуться из неё или завершить её, досрочно, начинает работать на уровне самих выражений. То есть try — это выражение, которое внезапно в какой-то момент может сделать return из нашей функции. Это не очень нравится авторам языка, потому что делается не очень явно.

func doSomething(in Dto) (err error) {
    try(validate(in))
    try(one())
    try(two())
    try(three())
    return nil
}

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

  • можно частично реализовать в userland;

  • ломается при оборачивании ошибок.

Если мы хотим как-то обрабатывать ошибки, оборачивать их или ещё что-то с ними делать, то это не очень удобно делать при помощи try/catch.

Добавление спецсимволов

Следующая группа предложений — реализовать какой-то из предыдущих вариантов, но вместо слов использовать спецсимволы. Чаще всего предлагают знак вопроса или восклицательный знак. Например, мы вызываем метод:

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return err
    }
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
        return err
    }
    if err := three(); err != nil {
        return err
    }
    return nil
}

Пишем восклицательный знак, и это работает как метод try.

func doSomething(in Dto) error {
    validate!(in)
    one!()
    two!()
    three!()
    return nil
}

Почему его отклоняют:

  • трудночитаемый код: сходу непонятно, что это за восклицательный знак и знак вопроса. Читается он несколько хуже, особенно если вы не до конца знакомы со спекой языка;

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

Попытки упрощения if

Proposal этой группы касаются не только if, связанных с if err != nil, а в целом. Допустим, у нас есть if in.Name == "", мы обычно это пишем в таком виде:

func doSomething(in Dto) error {
    if in.Name == "" {
        return errors.New("empty name")
    }
    return nil
}

Опять вербозно, ведь пишем тернарные операторы.

func doSomething(in Dto) error {
    return in.Name == "" ? errors.New("empty name") : nil
}

То есть пишем in.Name == "" ?, если да, то возвращаем одно значение, нет —другое.

Эта группа содержит целую пачку подобного рода предложений, где вводятся тернарные операторы и другие механики, которые позволяют чуть короче писать if. Например, чтобы из if можно было сразу сделать return или сразу вернуть значение, которое можно присвоить переменной.

Отклоняют его по тем же причинам:

  • сложно читать код (по мнению авторов языка);

  • не факт, что это что-то упрощает в принципе. 

Да, в каких-то случаях можно писать чуть меньше строчек кода, но ценой того, что мы должны каждый раз думать, какой if надо использовать. Это становится не очень однозначным выбором.

Proposal от Ian Lance Taylor

Есть ещё один, последний на текущий момент и также отклонённый proposal от Яна Тейлора, который длительное время работал в Google конкретно над языком Go. К сожалению, недавно он принял решение уйти из компании.

Его предложение оформлено в виде дискуссии на GitHub.

Ян предлагает добавить возможность писать знак вопроса в конце тех методов, которые возвращают ошибку.

То есть вместо:

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return err
    }
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
        return err
    }
    if err := three(); err != nil {
        return err
    }
    return nil
}

Писать вот так:

func doSomething(in Dto) (err error) {
    validate(in) ?
    one() ?
    two() ?
    three() ?
    return nil
}

В этом случае код будет эквивалентен тому, чтобы написать if err != не nil, а return err. По сути, похоже на то, что я рассказывал в одном из вариантов предложений, которые до этого всегда отклонялись.

Суть в том, что знак вопроса — это самая краткая форма записи. Мы можем её чуть расширить при желании — например, поставить фигурные скобки и внутри написать тело, которое обычно писали внутри if.

func doSomething(in Dto) (err error) {
    validate(in) ? {
        return fmt.Errorf("validation failed: %w", err)
    }
    one() ?
    two() ?
    three() ?
    return nil
}

В данном случае мы пишем return и оборачиваем нашу ошибку во что-то ещё.

И здесь может возникнуть вопрос: откуда берётся этот самый err? Ведь функция здесь в явном виде ничего не возвращает, никакой переменной здесь не создаётся, есть только переменная в общей сигнатуре функции. 

Дело в том, что этот proposal предполагает implicit variable declaration — неявное объявление переменной. В таком блоке по умолчанию существует переменная err, получающая значение, которое наш метод возвращает. Если мы не хотим это использовать в таком виде, можем err написать явно после знака вопроса.

func doSomething(in Dto) (err error) {
    validate(in) ? err {
        return fmt.Errorf("validation failed: %w", err)
    }
    one() ?
    two() ?
    three() ?
    return nil
}

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

В итоге получается альтернативный синтаксис. 

Эквивалентом для if err := foo(); err != nil является запись foo()?

Если мы хотим вернуть из нашей функции какое-то значение, то пишем знак вопроса, а значение присваиваем переменной, если функция вообще возвращает значение переменной.

Наконец, мы можем написать более полный вариант, когда мы и значение, если надо, вернём, и ошибку обработаем.

В чем суть:

  • Комбинация улучшения if и магических символов

Этот proposal компонует разные варианты того, что до этого уже предлагалось. Это комбинация — мы можем, с одной стороны, сделать неявный return, а с другой — написать блок, который обрабатывает ошибк. Вдобавок ко всему есть дополнительный специальный символ — знак вопроса.

  • Небольшое уменьшение строк кода

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

  • Не появляется неявного изменения control flow

Хотя с одной стороны нет неявного control flow, мы не можем, например, всё это применить так, чтобы она использовалась где-то в выражении. Например, если мы пишем one + two плюс 2, в таком случае нельзя применять. Но, тем не менее, неявные return есть.

Минусы, на самом деле, тянутся от всех вариантов proposal, которые были до этого:

  • «магический синтаксис» — знак вопроса, который может иметь дополнительные вариации;

  • неявность возврата ошибки — нам непонятно, что здесь в этом месте есть return. В придачу ко всему, неявно объявляется и переменная err;

  • несколько способов сделать одно и то же.

Мы можем написать полностью if или знак вопроса в конце метода, или знак вопроса и ещё один блок, обрабатывающий ошибку и так далее. В общем, вариативно.

Реакция сообщества весьма неоднозначна, но большинство высказываются против. Если под дискуссией почитать комменты и посчитать число лайков/дизлайков, то видно, что дизлайков больше раза в полтора.

Предложения по изменению обработки ошибок больше не принимаются

Недавно в блоге Go появилась новая статья от Robert Griesemer под названием [ On | No ] syntactic support for error handling.

В ней Роберт подробно рассматривает историю предложений к обработке ошибок в языке Go и в конце приходит следующим выводам:

  • было сделано немало различных предложений, все из которых были отклонены;

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

Как итог: команда разработки языка приняла решение прекратить рассмотрение и прием любых предложений в этой области.

Поэтому, скорее всего, proposal от Ian Lance Taylor будет последним активным обсуждением в ближайшие годы.

Кликни здесь и узнаешь

Выводы

Сегодня мы сравнивали Go с другими языками, где ошибки представляют собой некие специальные синтаксические конструкции, где-то специальные виды типов данных или исключения, где есть специальные ключевые слова и подходы к тому, как их обрабатывать. 

К чему я пришёл:

  • ошибки как обычные значения — это прекрасно;

  • явная сигнатура возврата ошибок — это прекрасно;

  • отсутствие исключения — это прекрасно.

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

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

С другой стороны, действительно всё это весьма вербозно. Даже когда мы пишем простые return, они занимают много места.

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return err
    }
    if err := one(); err != nil {
        return err
    }
    if err := two(); err != nil {
        return err
    }
    if err := three(); err != nil {
        return err
    }
    return nil
}

А если мы их оборачиваем, то они ещё более вербозны.

func doSomething(in Dto) error {
    if err := validate(in); err != nil {
        return fmt.Errorf("%w: %w", ErrValidation, err)
    }
    if err := one(); err != nil {
        return fmt.Errorf("%w: %w", &DetailsError{Method: "one"}, err)
    }
    if err := two(); err != nil {
        return fmt.Errorf("%w: %w", &DetailsError{Method: "two"}, err)
    }
    if err := three(); err != nil {
        return fmt.Errorf("%w: %w", &DetailsError{Method: "three"}, err)
    }
    return nil
}

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

  • Autocomplete (особенно с LLM) очень помогают писать такой код

Мы все используем различные IDE и редакторы, в них во всех есть те или иные варианты autocomplete. Если мы используем autocomplete со всякими языковыми моделями, то они могут зачастую вообще за нас написать, допустим, весь блок кода с обработкой ошибок. То есть на самом деле времени при реальном написании тратится не так уж и много.

  • IDE умеют сворачивать блоки if err != nil

Также эти же самые редакторы позволяют сократить код, который мы смотрим. Они могут сделать то, что называется фолдинг (сворачивание кусочка кода). Например, if err != nil сворачивается в одну строчку — достаточно компактно выглядит.

Но в будущем…

Возможно, когда вы это читаете, информация уже немного устарела, потому что язык Go постоянно меняется. За последние несколько релизов мы получили довольно много нового, чего в течение 10 с лишним лет там не было, в том числе:

  • дженерики;

  • range over function.

По сути дела, мы получили итераторы и то, что можно назвать корутинами. 

Возможно, в будущем всё-таки появится новых подход к обработке ошибок, но на данный момент команда разработки Go приняла решение о прекращении даже рассмотрения новых предложение.

На мой взгляд, это очень хорошее решение, потому что мне нравится, что они оставляют обработку ошибок как есть, как просто значение. И не появляется новых механик обработки ошибок, обособленных типов данных, сигнатур функций.

Мне хочется, чтобы:

  • ошибки оставались «просто значениями»;

  • синтаксис не становился слишком «волшебным».

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

Если вы хотите оставаться в курсе современных методов и практик в Go, обязательно загляните на следующий сезон Golang Conf. Гарантирую много практических кейсов, полезной информации и нестандартных подходов.

Обязательно подписывайтесь на блог AvitoTech на Хабре, если вы еще этого не сделали — мы здесь часто пишем про Go и не только. Больше инфо о том, что мы делаем в команде AvitoTech вы также найдете по этой ссылке. Вакансии вы найдете вот здесь, рекомендую также глянуть наш Telegram-канал, там интересно. До встречи!

Комментарии (9)


  1. uvelichitel
    08.09.2025 16:26

    Как по мне, вы не с теми языками сравниваете) Я бы отважился сказать, что модель ошибок взята из Haskell. (Как впрочем и идея интерфейсов) В Haskell есть возвращаемые типы Maybe, Error, Either. Но Go это же не Haskell. Go проетировался прагматичней, Haskell для бедных)) Добавили только возможность возвращать два значения разных типов. Конструктора типов же нету)) Монаду или Maybe(type) не сделаешь. Поэтому в рамках предоставленного инструментария типов лучше решения то и не сколхозить. А предосталенный механизм вполне рационален. На мой взгляд)) Ну, generics же подвезли наконец (никому не нужный). Это, какой-никакой а конструктор типов. Думаю на генерализованных типах можно уже построить что нибудь элегантное для Error. Механизм instantiate то теперь есть...


    1. uvelichitel
      08.09.2025 16:26

      Вышеупомянутый Тейлор, кстати, бросал в [go-nuts] лозунг -- "А кому не слабо написать монаду на Go?" Никто не справился)) А Пайк писал -- "Haskell для очкариков. Бесполезное", ну или что то вроде того))


      1. Dhwtj
        08.09.2025 16:26

        Haskell уважаемый язык

        Go всего лишь практичный

        И кстати, в Go не только вербозная обработка ошибок, она ещё и без типов, что плохо. Нет exhaustive matching - компилятор не проверит что обработал все варианты:

        if err == io.EOF { ... }
        // забыл io.ErrUnexpectedEOF - упс


        1. uvelichitel
          08.09.2025 16:26

          Go практичный) Haskell "уже и потому хорошо знать, что он ум в порядок приводит"(c) М. В. Ломоносов)))
          Но посмотрите вакансии на hh.ru Большой ли там спрос на высоколобых хаскелеров?)))


        1. uvelichitel
          08.09.2025 16:26

          Чтобы exhaustive matching сделать, так то вы можете столбик
          if err != io.EOF { ... }
          ну и

          switch case //все же таки есть
          pattern matching по типам нету конечно))

          но
          switch type .case //все же есть


          1. blind_oracle
            08.09.2025 16:26

            Думается имеется в виду подход аля Раст, где если в match не проверил все варианты enum или не поставил catch-all - компилятор скажет фе


            1. uvelichitel
              08.09.2025 16:26

              Раст, я нахожу overingeneering, как и scala. Мне больше нравятся установки Роба Пайка. "Вещи должны быть настолько простыми, насколько возможно. Но не проще"(c)А. Эйнштейн, и даже раньше этот посыл присылался("Не нужно плодить сущности сверх необходимого"))
              Я попробовал вдупляться в Rust и Scala. И понял что это сложнее, чем моя математика. И перебор в смеси абстракций. И у меня больше сил и времени займет хорошо освоить инструмент чем алгоритмистика, математика и предметная область. Да и плюсы, С++, на мой взгляд, переусложнили) И Пайк мне нравится за идею минимально ортогональных абстракций. Haskell, по мне, ортогонален и прост)))
              Scala вроде сейчас не особенно популярна. А для Раста сейчас есть зарплаты?


              1. okhsunrog
                08.09.2025 16:26

                Вполне себе, искал в июле работу, нашёл за пару недель, получил два оффера. Вакансии на hh.ru по расту есть, и платят очень даже прилично, а взамен пишешь на очень приятном современном языке


  1. Beholder
    08.09.2025 16:26

    Из такого синтаксиса невозможно понять сходу, а бросаются или нет здесь какие-то исключения, потому что в Python именно исключения являются способом возврата ошибок из того или иного метода. Мы ничего здесь не видим.

    В спецификации Java Virtual Machine, например, сказано, что теоретически исключение может вылететь в любом месте кода, на буквально каждом байте байт-кода. И это надо всегда иметь в виду. Жить с этим и программировать можно. Не забывать когда нужно ставить try-catch-finally.

    В 95% случаев нам не нужны ошибки как значения, а нужно лишь прервать исполнение и показать где и что произошло. Исключения с этим справляются, а простыня из if err != nil избыточна.