Skip to content

Manejo de Errores

En Go hay tres niveles de excepciones:

  • error: error normal de flujo que debe manejarse; ignorarlo no causará que el programa falle
  • panic: problema muy grave, el programa debe salir inmediatamente después de manejarlo
  • fatal: problema extremadamente crítico, el programa debe terminar inmediatamente

Para ser precisos, Go no tiene excepciones, se manifiestan a través de errores. Del mismo modo, Go no tiene sentencias try-catch-finally. Los fundadores de Go querían que los errores fueran controlables, no querían que todo requiriera anidar muchos try-catch, por lo que en la mayoría de los casos se devuelven como valores de retorno de funciones. Ejemplo:

go
func main() {
  // abrir un archivo
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

La intención de este código es clara: abrir un archivo llamado README.txt. Si falla, la función devuelve un error y se imprime el mensaje de error. Si el error es nil, significa que se abrió correctamente y se imprime el nombre del archivo.

Parece más conciso que try-catch, pero si hay muchas llamadas a funciones, el código estará lleno de sentencias if err != nil. Por ejemplo, el siguiente código para calcular el hash de un archivo tiene tres if err != nil:

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

Por esta razón, el manejo de errores es uno de los puntos más criticados de Go. El código fuente de Go tiene muchas líneas con if err != nil. Rust también devuelve valores de error, pero nadie lo critica porque lo resuelve con azúcar sintáctica. En comparación con Rust, Go tiene muy poca azúcar sintáctica.

Sin embargo, debemos ver las cosas de forma dialéctica. Todo tiene pros y contras. Las ventajas del manejo de errores en Go son:

  • Menor carga mental: si hay un error, se maneja; si no, se devuelve
  • Legibilidad: como el manejo es muy simple, en la mayoría de los casos es fácil de entender
  • Fácil depuración: cada error se produce como valor de retorno de una llamada a función, se puede rastrear capa por capa, rara vez aparece un error sin saber de dónde viene

Pero también hay desventajas:

  • Los errores no tienen información de pila (necesita paquetes de terceros o encapsulamiento propio)
  • Feo, mucho código repetitivo (depende del gusto personal)
  • Los errores personalizados se declaran con var, son variables no constantes (realmente no debería ser así)
  • Problema de ocultamiento de variables

Las propuestas y discusiones sobre el manejo de errores en Go nunca han cesado desde el nacimiento del lenguaje. Hay un chiste: si puedes aceptar el manejo de errores de Go, entonces eres un Gopher calificado.

TIP

Aquí hay dos artículos del equipo de Go sobre manejo de errores, si estás interesado:

error

error es un error de flujo normal, su aparición es aceptable, en la mayoría de los casos debe manejarse, aunque también puede ignorarse. La gravedad de error no es suficiente para detener todo el programa. error es una interfaz predefinida con un solo método Error() que devuelve una cadena para mostrar el mensaje de error.

go
type error interface {
   Error() string
}

error ha tenido grandes cambios en su historia. En la versión 1.13, el equipo de Go introdujo errores en cadena y proporcionó un mecanismo de verificación de errores más completo, que se presentará a continuación.

Creación

Hay varias formas de crear un error. La primera es usar la función New del paquete errors:

go
err := errors.New("este es un error")

La segunda es usar la función Errorf del paquete fmt para obtener un error con parámetros formateados:

go
err := fmt.Errorf("este es un error con %d parámetro formateado", 1)

Aquí hay un ejemplo completo:

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("deben ser enteros positivos")
   }
   return i + j, nil
}

En la mayoría de los casos, para mejor mantenibilidad, no se crean errores temporalmente, sino que se usan errores comunes como variables globales. Ejemplo del archivo os/errors.go:

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

Como se puede ver, todas están definidas con var como variables.

Errores Personalizados

Implementando el método Error(), se puede personalizar fácilmente un error. Por ejemplo, errorString del paquete errors es una implementación simple:

go
func New(text string) error {
   return &errorString{text}
}

// estructura errorString
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

Como la implementación de errorString es demasiado simple y no tiene suficiente capacidad de expresión, muchas bibliotecas de código abierto, incluidas las oficiales, eligen personalizar errores para satisfacer diferentes necesidades.

Propagación

En algunos casos, la función llamada por el llamador devuelve un error, pero el llamador no es responsable de manejarlo, por lo que también devuelve el error a la capa superior. Este proceso se llama propagación. Los errores pueden envolverse capa por capa durante la propagación. Cuando el llamador superior quiere determinar el tipo de error para manejarlo de manera diferente, puede no poder identificar la categoría del error o juzgarlo mal. Los errores en cadena surgieron para resolver esta situación.

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrapError también implementa la interfaz error y tiene un método adicional Unwrap que devuelve una referencia al error original. Envueltos capa por capa, forman una cadena de errores. Siguiendo la cadena, es fácil encontrar el error original. Como esta estructura no se expone externamente, solo se puede crear usando la función fmt.Errorf:

go
err := errors.New("este es un error original")
wrapErr := fmt.Errorf("error, %w", err)

Al usarlo, se debe usar el verbo de formato %w y el parámetro solo puede ser un error válido.

Manejo

El último paso del manejo de errores es cómo manejar y verificar errores. El paquete errors proporciona varias funciones convenientes:

go
func Unwrap(err error) error

La función errors.Unwrap() se usa para desenvolver una cadena de errores. Su implementación interna es simple:

go
func Unwrap(err error) error {
   u, ok := err.(interface { // afirmación de tipo, verifica si implementa el método
      Unwrap() error
   })
   if !ok { // si no lo implementa, es un error básico
      return nil
   }
   return u.Unwrap() // de lo contrario, llama a Unwrap
}

Después de desenvolver, devuelve el error envuelto por la cadena actual. El error envuelto puede seguir siendo una cadena de errores. Si se quiere encontrar un valor o tipo específico en la cadena, se puede buscar recursivamente, pero la biblioteca estándar ya proporciona funciones similares.

go
func Is(err, target error) bool

La función errors.Is se usa para determinar si una cadena de errores contiene un error específico. Ejemplo:

go
var originalErr = errors.New("this is an error")

func wrap1() error { // envuelve el error original
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // error original
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // si se usa if err == originalErr será false
      fmt.Println("original")
   }
}

Por lo tanto, al verificar errores, no se debe usar el operador ==, sino errors.Is().

go
func As(err error, target any) bool

La función errors.As() busca el primer error de tipo coincidente en la cadena de errores y asigna el valor al err pasado. En algunos casos, es necesario convertir un error de tipo error a un tipo de implementación de error específico para obtener detalles más detallados. Usar afirmación de tipo en una cadena de errores es ineficaz porque el error original está envuelto en una estructura, por eso se necesita la función As. Ejemplo:

go
type TimeError struct { // error personalizado
   Msg  string
   Time time.Time // registra el tiempo en que ocurrió el error
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // envuelve el error original
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // error original
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // verifica si la cadena de errores tiene un error de tipo *TimeError
   if errors.As(err, &myerr) { // imprime el tiempo de TimeError
      fmt.Println("original", myerr.Time)
   }
}

target debe ser un puntero a error. Como al crear la estructura se devuelve un puntero a la estructura, error es de tipo *TimeError, entonces target debe ser de tipo **TimeError.

Sin embargo, el paquete errors oficial no es suficiente porque no tiene información de pila y no se puede localizar. Generalmente se recomienda usar otro paquete mejorado oficial:

github.com/pkg/errors

Ejemplo:

go
import (
  "fmt"
  "github.com/pkg/errors"
)

func Do() error {
  return errors.New("error")
}

func main() {
  if err := Do(); err != nil {
    fmt.Printf("%+v", err)
  }
}

Salida:

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

Con formato de salida, se puede ver la información de la pila. Por defecto no se imprime la pila. Este paquete es una versión mejorada del paquete errors de la biblioteca estándar, también escrito por el equipo oficial, aunque no se sabe por qué no se incluyó en la biblioteca estándar.

panic

panic, traducido como pánico, representa un problema de programa muy grave. El programa debe detenerse inmediatamente para manejarlo, de lo contrario, el programa se detiene y muestra la información de la pila. panic es la forma en que Go expresa excepciones en tiempo de ejecución, generalmente aparece en operaciones peligrosas, principalmente para detener pérdidas a tiempo y evitar consecuencias más graves. Sin embargo, panic hace el trabajo de limpieza del programa antes de salir, y también se puede recuperar para que el programa continúe ejecutándose.

El siguiente es un ejemplo de escribir un valor en un map nil, que definitivamente activará panic:

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

Cuando hay múltiples corrutinas en el programa, si alguna corrutina tiene un panic y no se captura, todo el programa fallará.

Creación

Crear explícitamente un panic es muy simple, usando la función incorporada panic. La firma de la función es:

go
func panic(v any)

La función panic acepta un parámetro v de tipo any. Cuando se imprime la información de la pila de error, v también se imprime. Ejemplo de uso:

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("parámetros de conexión de datos inválidos")
  }
    // ... otra lógica
}

Cuando falla la inicialización de la conexión a la base de datos, el programa no debería iniciarse, porque sin base de datos el programa no tiene sentido ejecutarlo, por lo que aquí se debe lanzar un panic:

panic: parámetros de conexión de datos inválidos

Limpieza

Antes de que el programa salga por un panic, hará algo de trabajo de limpieza, como ejecutar sentencias defer:

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

Salida:

C
B
A
panic: panic

Y las sentencias defer de las funciones superiores también se ejecutarán. Ejemplo:

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

Salida:

C
2
1
B
A
panic: panic

También se puede anidar panic en defer. Aquí hay un ejemplo más complejo:

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

El orden de ejecución de panic anidado en defer es consistente. Cuando ocurre panic, la lógica posterior no se puede ejecutar.

C
2
1
A
panic: panicB
        panic: panicA

En resumen, cuando ocurre panic, se sale inmediatamente de la función actual y se ejecuta el trabajo de limpieza de la función actual, como defer, luego se propaga hacia arriba, y las funciones superiores también realizan trabajo de limpieza hasta que el programa deja de ejecutarse.

Cuando una corrutina hija tiene un panic, no se activa el trabajo de limpieza de la corrutina actual. Si no se recupera el panic hasta que la corrutina hija termina, el programa se detendrá directamente.

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // la corrutina padre espera bloqueada a que la hija termine
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

Salida:

C
2
1
panic: panicB

Como se puede ver, ninguna de las sentencias defer en demo() se ejecutó y el programa salió directamente. Cabe señalar que sin waitGroup para bloquear la corrutina padre, la velocidad de ejecución de demo() podría ser más rápida que la de la corrutina hija, y el resultado de salida sería muy confuso. Modifiquemos un poco el código:

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // el trabajo de limpieza de la corrutina padre toma 20ms
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // la corrutina hija debe ejecutar algo de lógica, toma 1ms
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

Salida:

C
D
2
1
panic: panicB

En este ejemplo, cuando la corrutina hija tiene un panic, la corrutina padre ya había completado la ejecución de la función y entró en el trabajo de limpieza. Al ejecutar el último defer, ocurrió el panic de la corrutina hija, por lo que el programa salió directamente.

Recuperación

Cuando ocurre un panic, se puede usar la función incorporada recover() para manejarlo a tiempo y asegurar que el programa continúe ejecutándose. Debe ejecutarse en una sentencia defer. Ejemplo de uso:

go
func main() {
   dangerOp()
   fmt.Println("programa terminó normalmente")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic recuperado")
      }
   }()
   panic("ocurrió panic")
}

El llamador no sabe en absoluto que ocurrió un panic dentro de la función dangerOp(). El programa ejecuta la lógica restante y sale normalmente. La salida es:

ocurrió panic
panic recuperado
programa terminó normalmente

Pero de hecho, el uso de recover() tiene muchas trampas implícitas. Por ejemplo, usar recover en un closure dentro de defer:

go
func main() {
  dangerOp()
  fmt.Println("programa terminó normalmente")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("panic recuperado")
      }
    }()
  }()
  panic("ocurrió panic")
}

La función closure se puede considerar como una llamada a función. panic se propaga hacia arriba, no hacia abajo, naturalmente la función closure no puede recuperar el panic. La salida es:

panic: ocurrió panic

Además, hay una situación muy extrema: el parámetro de panic() es nil.

go
func main() {
   dangerOp()
   fmt.Println("programa terminó normalmente")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic recuperado")
      }
   }()
   panic(nil)
}

En este caso, panic se recupera, pero no se imprime ningún mensaje de error.

Salida:

programa terminó normalmente

En resumen, la función recover tiene varios puntos a tener en cuenta:

  1. Debe usarse en defer
  2. Múltiples usos, solo uno puede recuperar el panic
  3. recover en closure no recupera ningún panic de funciones externas
  4. El parámetro de panic no puede ser nil

fatal

fatal es un problema extremadamente grave. Cuando ocurre un fatal, el programa debe detenerse inmediatamente sin ejecutar ningún trabajo de limpieza. Generalmente se llama a la función Exit del paquete os para salir del programa:

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("lógica normal")
}

Salida:

fatal

Los problemas de nivel fatal rara vez se activan explícitamente, en la mayoría de los casos se activan pasivamente.

Golang editado por www.golangdev.cn