TL;DR Пропустите до конца, чтобы увидеть код
Мотивация
Недавно я рассматривал различные причины, по которым встроенный в golang error
не очень хорош. На самом деле, если вы наберёте в Google «golang error handling», то найдёте множество других блогов с различными мнениями о том, как работать с ошибками в golang. Несмотря на то, что эта тема вызвала много разговоров, на самом деле нет большого прогресса.
Поэтому я выложу еще один пост в блоге, посвященный обработке ошибок в golang.
Начните с чистого листа
Первое, что я предложу, хотя это и спорно, это выбросить все, что есть об ошибках golang. Не используйте error
. Не используйте пакет "errors"
. Просто выбросьте все это (скорее всего, оно все равно вызывало у вас беспокойство). error
в любом случае был скорее руководством к действию.
Теперь, когда у нас нет обработки ошибок, мы открыты для нового мира возможностей. Как должна выглядеть ошибка? Какие данные должны содержаться в ошибке? Как обрабатывать ошибку после ее создания? Можем ли мы теперь расширить ошибки? В этом блоге я предлагаю ошибку в максимально простых терминах, чтобы ее можно было расширять только по мере необходимости.
Тип Error
type Error[T ~string] string
Да. Давайте просто начнем с нового типа под названием Error
. Обратите внимание, что мы также можем просто повторно использовать error
, поскольку он не является ключевым словом, но это будет несколько беспорядочно. Новый Error
— это просто string
. string
— это отличная отправная точка, потому что сообщения об ошибках обычно представляют собой строки, которые читаются человеком.
По сути, у нас есть все, что нужно. Ошибка может быть "whoops"
, а отсутствие ошибки будет ""
(пустая строка). Чтобы сделать использование вокруг этого немного приятнее для чтения, мы можем подключить IsSome() bool
и IsNone() bool
. Эти две вспомогательные функции будут просто проверять, является ли значение ""
.
Например:
// Create an error
err := errors.New[string]("whoops")
// Handle the error.
if err.IsSome() {
// Do something useful with the error.
}
Хотя это достаточный обзор создания ошибок и их обработки, мы можем сделать еще больше. На самом деле, хотя эти вспомогательные функции могут остаться в возможной реальной реализации, я бы предложил никогда не использовать их. Причина? На самом деле есть еще лучший подход, который использует общий T
на типе Error
.
Перечисления ошибок
Одной из основных проблем golang является отсутствие перечислений. Однако существуют способы создания перечислений, которые будут распознаваться линтером. Обычно это выглядит следующим образом:
const EnumType <some type>
const (
EnumTypeA = EnumType(<some value>)
EnumTypeB = EnumType(<some value>)
// ... and so on
)
Взяв пример с rust, было бы замечательно, если бы у нас было что-то похожее на match
, что заставило бы нас охватить все случаи перечисления. К счастью, следующим лучшим вариантом является исчерпывающий линтер.
В текущей версии golang error
, единственный способ создать исследуемую ошибку — это сделать var ErrMyError = errors.New("whoops")
. Но с этим есть проблема: это переменная, а не константа. Таким образом, это не настоящий поддельный enum, который распознают линтеры. Также не имеет смысла, чтобы это были изменяемые глобалы.
Предлагаемый новый тип Error
решает эту проблему, принимая T ~string
. Это означает, что тип самого Error
будет отличаться в зависимости от T
. T
также ограничена ~string
, что означает две вещи: T
может быть приведена к string
, T
может быть const. Обратите внимание, что даже если T
не хранится как значение в типе Error
, мы все равно можем ссылаться на него, поскольку он является частью определения типа. Это позволяет нам сохранить Error
в виде простой string
, но при этом иметь возможность преобразовать его в T
. Взяв еще немного вдохновения из rust, мы можем добавить к Error
функцию, которая будет это делать:
func (self Error[T]) Into() T {
return T(self)
}
Хотя все это кажется слишком сложным кодом, так как это просто синтаксический сахар вокруг string
, это позволяет нам теперь иметь такую обработку ошибок:
switch err := StringError(0); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
Для простой Error[string]
, default
ловит ошибку, если она существует, а case errors.ErrNone
ловит случай «нет ошибки». Однако, если у нас есть перечисление ошибок, то этот тип перечисления является T
и Into() T
возвращает ошибку этого типа. Это выглядит следующим образом:
_, err = EnumError(3)
switch err.Into() {
case ErrMyErrorOne:
fmt.Println(err)
case ErrMyErrorTwo:
fmt.Println(err)
case ErrMyErrorThree:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
Обратите внимание, что для каждого перечисления, которое было определено, есть свой случай, и нет default
. Технически, для полноты картины default
должен быть включен, но если вам нужно вернуть ошибку определенного типа, то этого не должно произойти, но это может произойти.
Что насчет пропущенных случаев? Если есть пропущенный случай, исчерпывающий линтер укажет на него и сообщит, что вы включили не все.
Расширение Error
golang error
позволит вам расширить его и сделать все, что вы хотите, и любая приемлемая замена должна обеспечивать такую же функциональность. Вполне возможно, что для некоторых случаев использования string
будет недостаточно. В этом случае нам все равно нужен способ расширения Error
без изменения шаблона.
Предположим, что нам нужен уникальный код ошибки для целей отслеживания/отладки, который присваивается каждому Error
. Мы можем расширить Error
, создав новый тип MyError[T ~string] struct
и встроив в него Error
. Теперь MyError
имеет доступ к тем же функциям, что и Error
, а также имеет преимущество хранения дополнительного значения для идентификатора ошибки.
type MyError[T ~string] struct {
errors.Error[T]
// Other data defined here.
errId int
}
func New[T ~string](err T, errId int) MyError[T] {
return MyError[T]{
Error: errors.New(err),
errId: errId,
}
}
Использование точно такое же, только MyError[T]
возвращается из функции вместо Error[T]
.
Преимущества/выгоды
Зачем же прилагать все эти усилия? Может быть, вас устраивает golang error
и вам нужно больше убеждений? Помимо того, что уже было сказано в разделах выше, есть и другие преимущества, которые дает предлагаемый подход.
Производительность
Предлагаемый Error
на самом деле более производителен, чем error
. Это связано с тем, что error
является интерфейсом, что означает, что буквально каждая ошибка, когда-либо созданная, требует выделения памяти. При прямом сравнении в бенчмарках это не так уж и много, но все эти выделения увеличиваются.
Вот мое неофициальное сравнение между созданием нового golang error
и Error
.
error
24.96 ns/op 16 B/op 1 allocs/op
Error
5.557 ns/op 0 B/op 0 allocs/op
Улучшение в ~4.5 раза — не так уж и плохо, даже если мы говорим о наносекундах.
Самодокументирующийся код
Документирование — это всегда борьба. Как только код меняется, документация должна быть обновлена. Однако использование перечислений в качестве типов в Error
позволяет нам четко видеть, какие ошибки возвращает функция. Я и раньше использовал подобные комментарии, но это просто не очень удобно:
// Foo does stuff
//
// Errors:
// * ErrFooFailure
func Foo() error { ... }
Вместо этого мы можем использовать что-то вроде этого:
type SampleError string
const (
ErrMyErrorOne = SampleError("error one")
ErrMyErrorTwo = SampleError("error two")
ErrMyErrorThree = SampleError("error three")
)
func EnumError(which int) (int, errors.Error[SampleError]) {
switch which {
case 1:
return 1, errors.New(ErrMyErrorOne)
case 2:
return 2, errors.New(ErrMyErrorTwo)
case 3:
return 3, errors.New(ErrMyErrorThree)
default:
return 0, errors.None[SampleError]()
}
}
Это ясно, связано с компилятором и легко для понимания. Код просто документирует сам себя.
«Принимать интерфейсы, возвращать структуры».
Это общее правило в коде golang, но error
полностью нарушает его, поскольку это интерфейс. После возврата ошибки нет никакого способа работать с ней, кроме как через вспомогательные функции, такие как errors.As()
. Необходимость вызывать Unwrap()
, errors.As()
и т.д. означает наличие неопределенности, что обычно приводит к ошибкам. Неопределенность, о которой я говорю, — это все вопросы, упомянутые в моем предыдущем сообщении, такие как форматирование ошибок. Поскольку Error
не является интерфейсом, мы можем работать с ним с уверенностью.
Отсутствие обертки
Это затрагивает еще одно небольшое преимущество в производительности. Когда "errors"
был отменен для этого предложения, мы также отказались от всего этого безумия с оберткой ошибок. error
используется как связный список. Поскольку Error
должен быть неизменным и «одноразовым», не существует цепочки ошибок, которую нужно пройти, чтобы проверить, получили ли мы какой-то тип error
. А с недавним выпуском go1.19
, если у вас окажется большой набор ошибок, то предложенный шаблон обработки ошибок switch
выиграет от таблицы переходов switch.
Следующие шаги
Это предложение — то, что я все еще прорабатываю, но я чувствую, что оно действительно перспективно. На самом деле, в процессе написания этой статьи я изменил несколько вещей, но основная концепция осталась. Я буду продолжать итерации и эксперименты в своих личных проектах, хотя может потребоваться некоторое убеждение, чтобы использовать итерацию этого предложения в реальных проектах.
TL;DR Просто покажите мне код!
error.go
package errors
import (
"fmt"
)
const ErrNone = ""
func None[T ~string]() Error[T] {
return Error[T]("")
}
func New[T ~string](format T, values ...any) Error[T] {
return newError(format, values...)
}
type Error[T ~string] string
func newError[T ~string](format T, values ...any) Error[T] {
var err string
if len(values) != 0 {
// Do not call fmt.Sprintf() if not necessary.
// Major performance improvement.
// Only necessary if there are any values.
err = fmt.Sprintf(string(format), values...)
} else {
err = string(format)
}
return Error[T](err)
}
func (self Error[T]) IsNone() bool {
return self == ""
}
func (self Error[T]) IsSome() bool {
return !self.IsNone()
}
func (self Error[T]) Into() T {
return T(self)
}
error_example_test.go
package errors_test
import (
"fmt"
"experimentation/errors"
)
func StringError(which int) errors.Error[string] {
switch which {
case 1:
return errors.New("error: %s", "whoops")
default:
return errors.None[string]()
}
}
func ExampleStringError() {
switch err := StringError(0); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
switch err := StringError(1); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
// Output:
// no error
// error: whoops
}
type SampleError string
const (
ErrMyErrorOne = SampleError("error one")
ErrMyErrorTwo = SampleError("error two")
ErrMyErrorThree = SampleError("error three")
)
func EnumError(which int) (int, errors.Error[SampleError]) {
switch which {
case 1:
return 1, errors.New(ErrMyErrorOne)
case 2:
return 2, errors.New(ErrMyErrorTwo)
case 3:
return 3, errors.New(ErrMyErrorThree)
default:
return 0, errors.None[SampleError]()
}
}
func ExampleEnumError() {
_, err := EnumError(0)
switch err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
_, err = EnumError(3)
switch err.Into() {
case ErrMyErrorOne:
fmt.Println(err)
case ErrMyErrorTwo:
fmt.Println(err)
case ErrMyErrorThree:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
// Output:
// no error
// error three
}
type MyError[T ~string] struct {
errors.Error[T]
// Other data defined here.
errId int
}
func New[T ~string](err T, errId int) MyError[T] {
return MyError[T]{
Error: errors.New(err),
errId: errId,
}
}
func (self MyError[T]) String() string {
return fmt.Sprintf("error: %s, id: %d", self.Error, self.errId)
}
func MyErrorFn() MyError[string] {
return New("whoops", 123)
}
func ExampleMyError() {
switch err := MyErrorFn(); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
// Output:
// error: whoops, id: 123
}