Об (отсутствии) синтаксической поддержки обработки ошибок в Go
- четверг, 5 июня 2025 г. в 00:00:07
Программисты на Go уже давно и долго жалуются на слишком многословную обработку ошибок. Все мы близко (а иногда и болезненно) знакомы со следующим шаблоном кода:
x, err := call()
if err != nil {
// обработка err
}
Проверка if err != nil
встречается настолько часто, что может становиться объёмнее остального кода. Обычно это происходит в программах, выполняющих много вызовов API, в которых обработка ошибок рудиментарна и они просто возвращаются. Некоторые программы в итоге выглядят примерно так:
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err != nil {
return err
}
y, err := strconv.Atoi(b)
if err != nil {
return err
}
fmt.Println("result:", x + y)
return nil
}
Из десятка строк кода тела этой функции реальную работу выполняют только четыре (вызовы и последние две строки). Остальные шесть строк — это шум. Код слишком многословен, поэтому неудивительно, что жалобы на обработку ошибок уже годами находятся на вершине списков в опросах разработчиков. (Какое-то время жалобы на обработку ошибок обгоняла только досада из-за отсутствия дженериков, но теперь, когда Go поддерживает дженерики, обработка ошибок снова вернулась на первое место.)
Команда разработчиков Go воспринимает отзывы сообщества со всей серьёзностью, поэтому мы много лет пытались придумать решение этой проблемы.
Первая явная попытка, предпринятая командой Go, произошла ещё в 2018 году, когда Расс Кокс формально описал проблему в рамках того, что мы тогда называли инициативой Go 2. Он изложил возможное решение, основанное на дизайне драфта Марселя ван Лохузена. Дизайн был основан на механизме check
и handle
и казался достаточно целостным. В драфт был включён подробный анализ альтернативных решений, в том числе сравнения с методиками, используемыми в других языках. Если у вас возникнет вопрос, рассматривалась ли раньше какая-то конкретная методика обработки ошибок, то прочитайте этот документ!
// реализация printSum, использующая предложенный механизм check/handle.
func printSum(a, b string) error {
handle err { return err }
x := check strconv.Atoi(a)
y := check strconv.Atoi(b)
fmt.Println("result:", x + y)
return nil
}
Решение с check
и handle
расценили слишком сложным, и почти год спустя, в 2019 году, мы представили гораздо более простое и печально известное сегодня предложение try
. Оно развивало идеи check
и handle
, но псевдоключевое слово check
превратилось во встроенную функцию try
, а от блока handle
мы избавились. Чтобы изучить эффект встроенной функции try
, мы написали простой инструмент (tryhard), переписывающий обработку ошибок так, чтобы она использовала try
. Предложение активно обсуждали: оно набрало девятьсот комментариев в GitHub issue.
// реализация printSum с использованием предложенного механизма try.
func printSum(a, b string) error {
// используем defer, чтобы повысить удобство ошибок перед возвратом
x := try(strconv.Atoi(a))
y := try(strconv.Atoi(b))
fmt.Println("result:", x + y)
return nil
}
Однако try
влияла на поток управления, поскольку в случае ошибки выполняла возврат из внешней функции из выражений с потенциально глубокой вложенностью, что скрывало бы этот поток управления. Из-за этого наше предложение оказалось неприемлемым для многих разработчиков, и, несмотря на большие усилия, приложенные к разработке этого предложения, мы решили отказаться и от него. Оглядываясь назад, можно предположить, что лучше было бы ввести новое ключевое слово: это можно сделать сегодня, потому что теперь у нас есть гораздо более удобный контроль за версией языка благодаря файлам go.mod
и директивам, применяемым к конкретным файлам. Ограничив использование try
только присвоениями и операторами, мы бы, возможно, могли решить часть других проблем. Недавнее предложение Джимми Фрэша, которое, по сути, возвращается к исходному дизайну check
и handle
, устраняя некоторые его недостатки, движется в этом направлении.
Предложение try
вызвало обширную реакцию и обсуждения, в том числе привело к публикации серии постов Расса Кокса «Thinking about the Go Proposal Process». В ней он пришёл к выводу, что мы снизили шансы на более удачный результат, представив почти полностью готовое предложение, почти не оставив места для отзывов сообщества и установив «пугающие» сроки внедрения. Цитата из «Go Proposal Process: Large Changes»: «сегодня можно сказать, что try
было достаточно большим изменением […] которое должно было стать дизайном второго драфта, а не окончательным предложением с графиком внедрения». Но вне зависимости от возможных неудач в реализации процесса и коммуникаций, пользователи в целом были сильно против этого предложения.
В то время у нас не было более приемлемого решения, и мы много лет не занимались разработкой изменений синтаксиса обработки ошибок. Однако многих участников сообщества вдохновил наш пример, и образовался стабильный поток предложений с решениями проблемы обработки ошибок. Многие из них были очень похожи друг на друга, часть была интересной, часть непонятной, часть нереализуемой. Чтобы отслеживать расширяющуюся территорию изменений, ещё спустя год Иэн Лэнс Тейлор создал зонтичный issue со сводкой текущего состояния предложений по улучшению обработки ошибок. Для упорядочивания отзывов, обсуждений и статей по теме была создана Go Wiki. Независимо от нас другие люди начали собирать коллекции множества предложений по обработке ошибок, поступающих в течение всех этих лет. Их общий объём впечатляет: например, попробуйте изучить пост Шона Ляо «go error handling proposals».
Жалобы на отсутствие краткости при обработке ошибок продолжали поступать (см. Go Developer Survey 2024 H1 Results), поэтому после серии постепенно совершенствовавшихся внутренних предложений разработчиков Go Иэн Лэнс Тейлор опубликовал в 2024 году пост «reduce error handling boilerplate using ?
». На этот раз идея заключалась в заимствовании конструкции, реализованной в Rust, а именно оператора ?
. Расчёт был на то, что, положившись на уже готовый механизм с устоявшимся форматом и учтя наши уроки за много лет, мы наконец-то сможем добиться прогресса. В небольших неформальных исследованиях пользователей, в которых программистам показывали код на Go с использованием ?
, подавляющее большинство участников правильно разобралась со смыслом кода, и это убедило нас сделать ещё одну попытку. Чтобы иметь возможность оценить влияние изменений, Иэн написал инструмент, преобразующий обычный код на Go в код, использующий новый синтаксис предложения, а также добавил прототип этой фичи в компилятор.
// реализация printSum с использованием предложенного оператора "?".
func printSum(a, b string) error {
x := strconv.Atoi(a) ?
y := strconv.Atoi(b) ?
fmt.Println("result:", x + y)
return nil
}
К сожалению, как и в случае с другими идеями по обработке ошибок, новое предложение тоже быстро получило множество комментариев и рекомендаций по незначительным изменениям, часто основанных на личных предпочтениях. Иэн закрыл предложение и перенёс его содержимое в обсуждение, чтобы упростить общение и собрать дополнительные отзывы. Немного видоизменённая версия была воспринята чуть позитивнее, но в целом всё равно не получила широкой поддержки.
Спустя столько лет безуспешных попыток, накопив три полномасштабных предложения со стороны команды разработчиков Go и в буквальном смысле сотни (!) предложений сообщества, большинство из которых оказалось вариациями на одну и ту же тему, ни одно из них не завоевало достаточной (не говоря уже о подавляющей) поддержки. Возник вопрос: как нам двигаться дальше? И нужно ли двигаться вообще?
Мы думаем, что нет.
Точнее, мы считаем, что нужно перестать пытаться решить синтаксическую проблему; по крайней мере, в ближайшем будущем. Процесс согласования предложений оправдывает это решение следующим образом:
Цель процесса согласования предложений заключается в обеспечении общего консенсуса о результате в приемлемых временных рамках. Если при проверке предложений не удаётся прийти к общему консенсусу в обсуждении issue в issue tracker, то обычно результатом становится отклонение предложения.
Кроме того:
Может оказаться так, что при проверке предложения общего консенсуса добиться не удаётся, но тем не менее очевидно, что предложение не должно быть полностью отклонено. […] Если группа проверки предложения не может ни прийти к консенсусу, ни к следующему этапу предложения, то решение о дальнейшем пути возлагается на архитекторов Go […], которые изучают обсуждение и стремятся прийти к консенсусу в своём кругу.
Ни одному из предложений по обработке ошибок не удалось достичь ничего похожего на консенсус, поэтому все они были отклонены. Даже самые опытные члены команды Go в Google не смогли на текущий момент единогласно выбрать наилучший путь (возможно, ситуация когда-нибудь изменится). Но без подавляющего консенсуса мы не можем двигаться вперёд.
Вот весомые аргументы в пользу сохранения статуса-кво:
Если бы в Go синтаксический сахар обработки ошибок возник на ранних этапах развития, то немногие бы оспаривали его сегодня. Но прошло уже 15 лет, возможность упущена; к тому же, в Go есть вполне удобный способ обработки ошибок, пусть он иногда и кажется слишком длинным.
Рассмотрим это под другим углом: допустим, мы пришли сегодня к идеальному решению. Если внедрить его в язык, то мы получим вместо одной недовольной группы пользователей (голосующих за изменения) другую (предпочитающую статус-кво). Мы находились в схожей ситуации, когда решили добавить в язык дженерики, однако здесь есть важное отличие: сегодня никого не заставляют использовать дженерики, а хорошие дженерик-библиотеки написаны так, что пользователи благодаря выводу типов по большей части могут закрыть глаза на то, что это дженерики. Если же в язык добавят новую синтаксическую конструкцию для обработки ошибок, то практически всем придётся использовать её, чтобы поддерживать идиоматичность кода.
Предотвращение появления дополнительного синтаксиса соответствует одному из правил дизайна Go: не создавать нескольких способов реализации одной и той же функциональности. Из этого правила есть исключения в активно используемых областях, например, в присвоениях. Забавно, что возможность повторного объявления переменных в коротких объявлениях переменных (:=
) была добавлена для решения проблемы, возникшей из-за обработки ошибок: без повторных объявлений цепочки проверок на ошибки требуют переменной err
с разным для каждой проверки именем (или добавления объявлений отдельных переменных). В то время, вероятно, лучше было бы добавить больше синтаксической поддержки обработки ошибок. Тогда правило повторного объявления могло и не понадобиться, а благодаря его отсутствию и не возникли бы различные связанные с ним осложнения.
Если изучить код обработки ошибок, то можно заметить, что многословность становится не так важна, если ошибки действительно обрабатываются. Для хорошей обработки ошибок часто требуется добавление к ошибке дополнительной информации. Например, в опросах пользователи часто оставляют комментарии о нехватке связанных с ошибками трассировок стеков. Эту проблему можно решить при помощи вспомогательных функций, создающих и возвращающих расширенную ошибку. В этом примере (гипотетическом) относительный объём бойлерплейта намного меньше:
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err != nil {
return fmt.Errorf("invalid integer: %q", a)
}
y, err := strconv.Atoi(b)
if err != nil {
return fmt.Errorf("invalid integer: %q", b)
}
fmt.Println("result:", x + y)
return nil
}
Новая функциональность стандартной библиотеки также может снизить объём бойлерплейта обработки ошибок в духе поста Роба Пайка «Errors are values». Например, в некоторых случаях для одновременной обработки серии ошибок можно использовать cmp.Or
:
func printSum(a, b string) error {
x, err1 := strconv.Atoi(a)
y, err2 := strconv.Atoi(b)
if err := cmp.Or(err1, err2); err != nil {
return err
}
fmt.Println("result:", x+y)
return nil
}
Написание, чтение и отладка кода — это достаточно различающиеся действия. Написание повторяющихся проверок на ошибки может казаться скучным занятием, но современные IDE имеют мощные функции автозавершения кода, некоторые даже с поддержкой LLM. Таким инструментам очень легко писать базовые проверки на ошибки. Многословность при чтении кода по большей части проста в понимании, но и в этом могут помочь инструменты. Например, IDE с настройками языка Go может иметь переключатель для сокрытия кода обработки ошибок. Такие переключатели уже существуют для других блоков кода, например, для тел функций.
При отладке кода обработки ошибок полезна возможность быстрого добавления println
или отдельной строки для установки контрольной точки в отладчике. Это легко реализовать, если уже существует отдельный оператор if
. Но если вся логика обработки ошибок скрыта за check
, try
или ?
, то может потребоваться преобразование всего кода в обычную конструкцию if
, что усложняет отладку и может даже привести к появлению незаметных багов.
Есть и соображения практичности: придумать новую идею синтаксиса для обработки ошибок легко, отсюда и берётся множество предпочтений со стороны сообщества разработчиков. Сложнее придумать хорошее решение, которое выдержало бы тщательный анализ. Для правильного проектирования изменений в языке и для их реализации требуются согласованные усилия. Но позже всё равно придётся платить за это высокую цену: весь код нужно будет переписать, обновить документацию и внести изменения в инструменты. С учётом всего этого, изменения в языке — очень дорогостоящий процесс, а команда разработчиков Go относительно мала и у неё есть много других приоритетных задач. (Но здесь всё может поменяться: приоритеты становятся другими, размеры команд уменьшаются и увеличиваются.)
Кроме того, некоторые из нас недавно получили возможность присутствовать на Google Cloud Next 2025, где у команды Go был свой павильон и где мы провели небольшой Go Meetup. Все пользователи Go, с которыми нам удалось поговорить, уверенно заявили, что нам не следует менять язык ради улучшения обработки ошибок. Многие сообщали, что отсутствие поддержки обработки ошибок в Go наиболее очевидна, когда переходишь на него с другого языка, где такая поддержка есть. Когда больше осваиваешься и начинаешь писать более идиоматичный код на Go, эта проблема становится гораздо менее важной. Разумеется, выборка была недостаточно репрезентативной, но множество людей на GitHub и их отзывы тоже можно считать ещё одним примером данных.
Разумеется, в пользу изменений тоже есть весомые аргументы:
Отсутствие качественной поддержки обработки ошибок остаётся основной причиной жалоб в опросах пользователей. Если команда Go действительно воспринимает отзывы пользователей серьёзно, то рано или поздно нам придётся с этим что-то делать. (Хотя подавляющей поддержки изменений в языке всё равно не наблюдается.)
Наверно, неправильно было бы стремиться исключительно к снижению количества символов. Было бы лучше сделать стандартную обработку ошибок хорошо заметной при помощи ключевого слова, и при этом всё равно избавиться от бойлерплейта (err != nil
). Такой подход может упростить чтение кода (для ревьюера!) и понимание того, какая ошибка обрабатывается, без необходимости дополнительной проверки, что повысило бы качество и безопасность кода. Это бы привело нас к самому началу, то есть к check
и handle
.
Мы не знаем, насколько на самом деле проблема синтаксической многословности проверки ошибок важнее, чем многословность хорошей обработки ошибок: конструирование ошибок — полезная часть API, важная и для разработчиков, и для конечных пользователей. Этот аспект нам бы хотелось изучить глубже.
Итак, пока ни одна попытка решения проблемы обработки ошибок не получила достаточной популярности. Если взглянуть на текущую ситуацию объективно, то нужно признать, что у нас нет ни всеобщего понимания проблемы, ни согласия о том, существует ли вообще эта проблема. Учитывая всё это, мы приняли следующее прагматичное решение:
В обозримом будущем команда разработчиков Go перестанет пытаться внедрить в язык синтаксические изменения для обработки ошибок. Также мы без подробного изучения будем закрывать все открытые и новые предложения, основной смысл которых связан с синтаксисом обработки ошибок.
Сообщество разработчиков приложило огромные усилия к исследованию и обсуждению этих вопросов. Пусть они и не привели к каким-то осязаемым изменениям в синтаксисе обработки ошибок, этот труд позволил нам улучшить многие другие аспекты языка Go и процессов. Возможно, в будущем у нас возникнет более чёткая картина относительно обработки ошибок. А пока мы собираемся сосредоточиться на развитии новых возможностей, улучшающих Go для всех и каждого.