Почему «ошибки это значения» в Go
Недавно я перевёл великолепную статью Роба Пайка «Ошибки это значения», которая не получила должной оценки на Хабре. Что неудивительно, поскольку была размещена в официальном блоге Go и рассчитана на Go программистов. Впрочем, суть статьи не всегда сразу очевидна и опытным программистам. И всё же, я считаю её посыл ключевым для понимания подхода к обработке ошибок в Go, поэтому постараюсь объяснить его своими словами.
Я хорошо помню своё первое впечатление от прочтения этой статьи в момент её выхода. Это было примерно следующее: «Странный пример тут выбран — очевидно же, что с исключениями код будет лаконичней; выглядит как попытка оправдаться, что и без исключений можно как-то сократить». При том что я никогда не был фанатом исключений, пример, который рассматривается в статье, прямо напрашивался на это сравнение. Что хотел сказать Пайк фразой «ошибки это значения» было не очень ясно.
В то же время, я понимал, что упускаю нечто важное, поэтому дал себе немного времени, чтобы впитать прочитанное. И в какой-то момент, вернувшись к статье, понимание пришло само собой.
Go просит программиста относиться к ошибкам также, как и к любому другому коду. Просит не рассматривать ошибки, как некоторую особую сущность, для которой нужен специальный подход/инструмент/паттерн, а трактовать код обработки ошибок также, как бы вы трактовали обычную логику вашей программы. Код обработки ошибки — это не особенная конструкция, это полноценная часть вашего кода, как и все остальные.
Представьте, как бы шел ход ваших мыслей, если бы речь шла об обычных языковых конструкция — переменных, условиях, значениях и т.д. — и примените их к обработке ошибочных ситуаций. Они являются сущностями того же самого уровня, они — часть логики. Вы же не игнорируете возвратные значения функций без особого на то повода, правда? Вы не просите от языка специального способа работы с boolean-значениями, потому что if — это слишком скучно. Вы не вопрошаете в замешательстве «А что мне делать, если я не знаю, что мне делать с результатом функции sqrt на этом уровне вложенности?». Вы просто пишете нужную вам логику.
Ещё раз — ошибки это обычные значения, и обработка ошибок — это такое же обычное программирование.
Давайте попробуем проиллюстрировать это примером, похожим на пример из оригинальной статьи. Допустим, у вас есть задача — «сделать несколько повторяющихся записей, подсчитать количество записанных байт и остановиться после 1024 байт». Вы начнете с примера «в лоб»:
var count, n int
n = write(“one”)
count += n
if count >= 1024 {
return
}
n = write(“two”)
count += n
if count >= 1024 {
return
}
// etc
play.golang.org/p/8033Wp9xly
Разумеется, вы сразу увидите, что не так с этим кодом и как его можно улучшить. Попробуем, следуя DRY, вынести повторяющийся код в отдельное замыкание:
var count int
cntWrite := func(s string) {
n := write(s)
count += n
if count >= 1024 {
os.Exit(0)
}
}
cntWrite(“one”)
cntWrite(“two”)
cntWrite(“three”)
play.golang.org/p/Hd12rk6wNk
Уже лучше, но всё же далеко от того, чтобы считать это хорошим кодом. Замыкание зависит от внешней переменной и использует os.Exit для выхода, что делает код слишком хрупким… Как будет идти ход мысли в этом случае? Посмотрим на проблему с такой стороны — у нас есть функция, которая делает не только запись, но ещё и хранит состояние и реализует определенную, изолированную, логику. Вполне логично создать отдельный тип writer-а для этого:
type cntWriter struct {
count int
w io.Writer
}
func (cw *cntWriter) write(s string) {
if cw.count >= 1024 {
return
}
n := write(s)
cw.count += n
}
func (cw *cntWriter) written() int { return cw.count }
func main() {
cw := &cntWriter{}
cw.write(“one”)
cw.write(“two”)
cw.write(“three”)
fmt.Printf(“Written %d bytes\n”, cw.count)
}
play.golang.org/p/66Xd1fD8II
Теперь это похоже на нормальный код — мы можем переиспользовать наш writer в других местах программы, он самодостаточен, легко тестируется и так далее. Немного многословно, но мы следуем проверенным практикам, чтобы облегчить себе и другим программистам работу с кодом в будущем.
А теперь просто замените «counter» на «error value» и вы получите практически один-в-один пример из оригинальной статьи. Но обратите внимание, как логично и легко шёл ход мысли по мере решения конкретной задачи. Вы не отвлекались на поиски «специальной» фичи языка для счетчика байт и путей передачи его между вызовами, вы не искали «особенного» способа проверки перехода за 1024 байта — вы просто молча реализовали нужную вам логику наиболее удачным способом.
Эта концепция — ошибки как значения — не просто «другой способ» или «новый дизайн», это концептуальный подход. Он может быть принят легко и естественно, а может казаться чем-то диким и неправильным, в зависимости от вашего предыдущего опыта. Зачастую нужно время, чтобы его осознать.
Безусловно, у такого подхода есть и свои минусы, и свои плюсы, как и во всех других подходах. Мы живем не в черно-белом мире, но подход Go тут является и зрелым и свежим одновременно, он чрезвычайно прост и при этом непривычен для понимания, и требует определенных усилий, чтобы прочувствовать всю мощь. Но, что самое важное — он отлично работает на практике.
Как только вы поймете эту идею, вы перестанете бороться с языком. Вы перестанете искать специальные способы работы с ошибками. Go заставляет вас уважать ошибки точно так же, как любой другой код. Вы просто обрабатываете их, как требует ход ваших мыслей, а не ждете, что язык сделает всё магически за вас, избавив от необходимости думать про ошибки. В долгосрочной перспективе, ваш код становится лучше и надежней, причём вы это можете даже сами не осознавать.
А теперь попробуйте ещё раз прочесть оригинальную статью — «Ошибки это значения» — но теперь посмотрите на неё с вышеописанной перспективы.
PS. Вариант статьи на английском тут.