Error Handling
In Go, there are three levels of exceptions:
error: Normal flow errors that need handling, the program won't crash even if ignoredpanic: Very serious problems, the program should exit immediately after handling the issuefatal: Extremely fatal problems, the program should exit immediately
Strictly speaking, Go language doesn't have exceptions; it represents them through errors. Similarly, Go doesn't have try-catch-finally statements. Go's creators wanted to make errors controllable and didn't want to nest piles of try-catch for everything, so in most cases, errors are returned as function return values. For example:
func main() {
// Open a file
if file, err := os.Open("README.txt"); err != nil {
fmt.Println(err)
return
}
fmt.Println(file.Name())
}The intent of this code is clear: open a file named README.txt. If opening fails, the function returns an error and outputs the error message. If the error is nil, it means opening succeeded, and the filename is output.
This seems simpler than try-catch, but if there are many function calls, if err != nil statements will be everywhere. For example, the following code is a demo for calculating file hash values. In this small piece of code, if err != nil appears three times.
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
}Because of this, the most criticized point about Go is its error handling. In Go source code, if err != nil accounts for a significant portion. Rust also returns error values, but no one complains about it because it solves this problem through syntactic sugar. Compared to Rust, Go's syntactic sugar can't be described as abundant - it's almost non-existent.
However, we should look at things dialectically. Everything has its pros and cons. Go's error handling has several advantages:
- Low mental burden: Handle errors when they occur, or return them if not handled
- Readability: Because the handling method is very simple, code is easy to understand in most cases
- Easy debugging: Every error is produced through function call return values, can be traced back layer by layer, rarely encountering situations where an error suddenly appears without knowing where it came from
But there are also many disadvantages:
- No stack information in errors (requires third-party packages or custom wrapping)
- Ugly, lots of repetitive code (depends on personal preference)
- Custom errors are declared with
var, they are variables not constants (indeed shouldn't be) - Variable shadowing issues
Proposals and discussions about Go error handling in the community have never stopped since Go's birth. There's a joke: if you can accept Go's error handling, then you're a qualified Gopher.
TIP
Here are two articles about error handling from the Go team, if you're interested:
error
error is a normal flow error. Its occurrence is acceptable. In most cases, it should be handled, but it can also be ignored. error is not severe enough to stop the entire program. error itself is a pre-defined interface with only one method Error(), which returns a string for outputting error information.
type error interface {
Error() string
}error has undergone major changes in history. In version 1.13, the Go team introduced chained errors and provided a more complete error checking mechanism, which will be introduced one by one.
Creation
There are several ways to create an error. The first is using the New function from the errors package.
err := errors.New("this is an error")The second is using the Errorf function from the fmt package, which can get a formatted error.
err := fmt.Errorf("this is error number %d with formatted parameters", 1)Below is a complete example:
func sumPositive(i, j int) (int, error) {
if i <= 0 || j <= 0 {
return -1, errors.New("must be positive integers")
}
return i + j, nil
}In most cases, for better maintainability, errors are not created temporarily. Instead, commonly used errors are treated as global variables. For example, the following code excerpt from os\errors.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"
)As you can see, they are all variables defined with var.
Custom Errors
By implementing the Error() method, you can easily customize errors. For example, errorString in the errors package is a very simple implementation.
func New(text string) error {
return &errorString{text}
}
// errorString struct
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}Because errorString is too simple and lacks expressiveness, many open-source libraries including the official library choose to customize errors to meet different error requirements.
Propagation
In some situations, the caller calls a function that returns an error, but the caller itself is not responsible for handling the error, so it also returns the error as a return value, throwing it to the upper-level caller. This process is called propagation. During propagation, errors may be wrapped layer by layer. When the upper-level caller wants to determine the error type to make different handling, they may not be able to identify the error category or make misjudgments. Chained errors were created to solve this situation.
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}wrappError also implements the error interface and adds a method Unwrap, which returns the internal reference to the original error. Under layer-by-layer wrapping, an error linked list is formed. Following the linked list upwards, it's easy to find the original error. Since this struct is not exposed externally, it can only be created using the fmt.Errorf function. For example:
err := errors.New("this is an original error")
wrapErr := fmt.Errorf("error, %w", err)When using it, you must use the %w format verb, and the parameter must be one valid error.
Handling
The last step in error handling is how to handle and check errors. The errors package provides several convenient functions for handling errors.
func Unwrap(err error) errorThe errors.Unwrap() function is used to unwrap an error chain. Its internal implementation is also very simple:
func Unwrap(err error) error {
u, ok := err.(interface { // Type assertion, whether it implements the method
Unwrap() error
})
if !ok { // Not implemented means it's a basic error
return nil
}
return u.Unwrap() // Otherwise call Unwrap
}After unwrapping, it returns the error wrapped by the current error chain. The wrapped error may still be an error chain. If you want to find the corresponding value or type in the error chain, you can recursively search and match. However, the standard library already provides similar functions.
func Is(err, target error) boolThe errors.Is function determines whether the error chain contains the specified error. An example is as follows:
var originalErr = errors.New("this is an error")
func wrap1() error { // Wrap original error
return fmt.Errorf("wrap error %w", wrap2())
}
func wrap2() error { // Original error
return originalErr
}
func main() {
err := wrap1()
if errors.Is(err, originalErr) { // If using if err == originalErr, it will be false
fmt.Println("original")
}
}So when judging errors, you should not use the == operator, but should use errors.Is().
func As(err error, target any) boolThe errors.As() function searches for the first type-matching error in the error chain and assigns the value to the passed err. In some cases, you need to convert an error type error to a specific error implementation type to get more detailed error information. Using type assertion on an error chain is invalid because the original error is wrapped in a struct. This is why the As function is needed. An example is as follows:
type TimeError struct { // Custom error
Msg string
Time time.Time // Record the time when error occurred
}
func (m TimeError) Error() string {
return m.Msg
}
func NewMyError(msg string) error {
return &TimeError{
Msg: msg,
Time: time.Now(),
}
}
func wrap1() error { // Wrap original error
return fmt.Errorf("wrap error %w", wrap2())
}
func wrap2() error { // Original error
return NewMyError("original error")
}
func main() {
var myerr *TimeError
err := wrap1()
// Check if there is a *TimeError type error in the error chain
if errors.As(err, &myerr) { // Output TimeError's time
fmt.Println("original", myerr.Time)
}
}target must be a pointer to error. Since a struct pointer is returned when creating the struct, error is actually of *TimeError type, so target must be of **TimeError type.
However, the official errors package is actually not sufficient because it doesn't have stack information and cannot locate errors. Generally, it's recommended to use another enhanced package from the official team:
github.com/pkg/errorsExample:
import (
"fmt"
"github.com/pkg/errors"
)
func Do() error {
return errors.New("error")
}
func main() {
if err := Do(); err != nil {
fmt.Printf("%+v", err)
}
}Output:
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:1650Through formatted output, you can see the stack information. By default, the stack is not output. This package is essentially an enhanced version of the standard library errors package, both written officially. It's unclear why it hasn't been merged into the standard library.
panic
panic translated to Chinese means "panic", indicating a very serious program problem. The program needs to stop immediately to handle the issue, otherwise the program stops running immediately and outputs stack information. panic is Go's expression of runtime exceptions. It usually appears in dangerous operations, mainly to cut losses in time and avoid more serious consequences. However, panic will do cleanup work before exiting, and panic can also be recovered to ensure the program continues running.
Below is an example of writing to a nil map, which will definitely trigger panic:
func main() {
var dic map[string]int
dic["a"] = 'a'
}panic: assignment to entry in nil mapTIP
When there are multiple goroutines in a program, if any goroutine has a panic and it's not caught, the entire program will crash
Creation
Explicitly creating a panic is very simple, just use the built-in function panic. The function signature is as follows:
func panic(v any)The panic function accepts a parameter v of type any. When outputting error stack information, v will also be output. An example is as follows:
func main() {
initDataBase("", 0)
}
func initDataBase(host string, port int) {
if len(host) == 0 || port == 0 {
panic("invalid database connection parameters")
}
// ...other logic
}When database connection initialization fails, the program shouldn't start because without a database, the program is meaningless to run. So a panic should be thrown here.
panic: invalid database connection parametersCleanup
Before the program exits due to panic, it will do some cleanup work, such as executing defer statements.
func main() {
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")
panic("panic")
defer fmt.Println("D")
}Output is:
C
B
A
panic: panicAnd defer statements in upstream functions will also execute. An example is as follows:
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)
}Output:
C
2
1
B
A
panic: panicdefer can also nest panic. Here's a more complex example:
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)
}The execution order of nested panic in defer remains the same. When panic occurs, subsequent logic cannot execute.
C
2
1
A
panic: panicB
panic: panicAIn summary, when panic occurs, the function exits immediately and executes cleanup work in the current function, such as defer. Then it propagates upward layer by layer, and upstream functions also perform cleanup work until the program stops running.
When a child goroutine has a panic, it won't trigger cleanup work in the current goroutine. If the panic is not recovered by the time the child goroutine exits, the program will stop running directly.
var waitGroup sync.WaitGroup
func main() {
demo()
}
func demo() {
waitGroup.Add(1)
defer func() {
fmt.Println("A")
}()
fmt.Println("C")
go dangerOp()
waitGroup.Wait() // Parent goroutine blocks waiting for child goroutine to complete
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
waitGroup.Done()
}Output is:
C
2
1
panic: panicBAs you can see, none of the defer statements in demo() executed before the program exited directly. Note that without waitGroup to block the parent goroutine, demo() might execute faster than the child goroutine, making the output very misleading. Let's slightly modify the code:
func main() {
demo()
}
func demo() {
defer func() {
// Parent goroutine cleanup work takes 20ms
time.Sleep(time.Millisecond * 20)
fmt.Println("A")
}()
fmt.Println("C")
go dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
// Child goroutine needs to execute some logic, takes 1ms
time.Sleep(time.Millisecond)
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
}Output is:
C
D
2
1
panic: panicBIn this example, when the child goroutine has a panic, the parent goroutine has already completed function execution and entered cleanup work. While executing the last defer, it happened to encounter the child goroutine having a panic, so the program exited directly.
Recovery
When panic occurs, using the built-in function recover() can handle it in time and ensure the program continues running. It must be run in a defer statement. An example is as follows:
func main() {
dangerOp()
fmt.Println("Program exited normally")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic recovered")
}
}()
panic("panic occurred")
}The caller has no idea that a panic occurred inside the dangerOp() function. The program executes the remaining logic and exits normally, so the output is:
panic occurred
panic recovered
Program exited normallyBut in fact, there are many hidden pitfalls in using recover(). For example, using recover again in a closure within defer:
func main() {
dangerOp()
fmt.Println("Program exited normally")
}
func dangerOp() {
defer func() {
func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic recovered")
}
}()
}()
panic("panic occurred")
}The closure function can be seen as calling a function. panic propagates upward, not downward, so naturally the closure function cannot recover the panic. The output is:
panic: panic occurredBesides this, there's also an extreme case where the parameter of panic() is nil.
func main() {
dangerOp()
fmt.Println("Program exited normally")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic recovered")
}
}()
panic(nil)
}In this case, panic will indeed recover, but no error information will be output.
Output:
Program exited normallyIn summary, the recover function has several points to note:
- Must be used in
defer - Multiple uses will only have one that can recover
panic - Closure
recoverwill not recover anypanicfrom external functions panicparameter must not benil
fatal
fatal is an extremely serious problem. When fatal occurs, the program needs to stop running immediately without executing any cleanup work. Usually, this is done by calling the Exit function from the os package to exit the program, as shown below:
func main() {
dangerOp("")
}
func dangerOp(str string) {
if len(str) == 0 {
fmt.Println("fatal")
os.Exit(1)
}
fmt.Println("Normal logic")
}Output:
fatalfatal level problems are rarely triggered explicitly in most cases; they are mostly triggered passively.
