19 Error Handling Part 1

19 Error Handling - Part 1 #

When it comes to error handling in Go, we have actually encountered it a few times before.

For example, we have declared variables of type error, and have also used the New function from the errors package. Today, I will use this article to summarize the related knowledge of error handling in Go, and discuss some key questions with you.

As we mentioned before, the error type is actually an interface type and a built-in type in Go. The declaration of this interface type only includes one method, which is the Error method. The Error method does not accept any parameters, but returns a result of type string. Its purpose is to return the string representation of the error message.

The common way to use the error type is to declare a result of this type at the end of the function declaration’s result list, and then after calling this function, check whether the last result value it returns is “not nil”.

If this value is “not nil”, then we enter the error handling process, otherwise we continue with the normal process. Here is an example, the code is in the demo44.go file.

package main

import (
	"errors"
	"fmt"
)

func echo(request string) (response string, err error) {
	if request == "" {
		err = errors.New("empty request")
		return
	}
	response = fmt.Sprintf("echo: %s", request)
	return
}

func main() {
	for _, req := range []string{"", "hello!"} {
		fmt.Printf("request: %s\n", req)
		resp, err := echo(req)
		if err != nil {
			fmt.Printf("error: %s\n", err)
			continue
		}
		fmt.Printf("response: %s\n", resp)
	}
}

Let’s first look at the declaration of the echo function. The echo function takes a parameter request of type string, and returns two results.

Both of these results have names, and the first result response is also of type string, which represents the result value after the function is executed normally.

The second result err is of type error, which represents the result value when the function encounters an error, and also includes the specific error message.

When the echo function is called, it will first check the value of the request parameter. If the value is an empty string, it will assign a value to the err result by calling the errors.New function, and then ignore the subsequent operations and return directly.

At this time, the value of the response result will also be an empty string. If the value of request is not an empty string, it will assign an appropriate value to the response result, and then return, at which point the value of the err result will be nil.

Let’s look at the code in the main function. After each call to the echo function, I assign the returned result values to variables resp and err, and always check whether the value of err is “not nil” first. If it is, I print the error message, otherwise I print the normal response message.

There are two points worth noting here. First, both the echo function and the main function use guard statements. I mentioned guard statements when talking about function usage. Simply put, they are used to check the preconditions of subsequent operations and perform corresponding processing.

For the echo function, the precondition for performing normal operations is that the passed parameter value must meet the requirements. And for the program calling the echo function, the precondition for subsequent operations is that the echo function must not encounter an error during execution.

When we are handling errors, we often use guard statements, to the point that some people complain, “My program is full of guard statements, it looks really ugly!”

However, I believe this may be a problem in program design. The concepts and styles of each programming language are almost different, and we often need to design programs in accordance with their texture, rather than using the programming ideas of another language to write programs in the current language.

Now let’s talk about the second point worth noting. When I generate an error value, I use the errors.New function.

This is the most basic way to generate error values. When we call it, we pass in an error message represented by a string, and it returns an error value that contains the error message. The static type of this value is of course error, and the dynamic type is a package-private type *errorString in the errors package.

Clearly, the errorString type has a pointer method that implements the Error method of the error interface. After this method is called, it will return the error message we previously passed in as it is. In fact, the Error method of an error value is equivalent to the String method of values of other types.

We already know that by calling the fmt.Printf function and providing the placeholder %s, we can print the string representation of a value.

For values of other types, as long as we can write a String method for this type, we can customize its string representation. For error values, their string representation depends on their Error method.

In the above situation, if the fmt.Printf function finds that the value being printed is of type error, it will call its Error method. These types of printing functions in the fmt package all do this.

By the way, when we want to generate an error value by using templating to generate error messages, we can use the fmt.Errorf function. What it actually does is to first call the fmt.Sprintf function to get the precise error message, then call the errors.New function to get an error value that contains this error message, and finally return this value.

Okay, now I will ask a question about judging specific error values. Today’s question is: What are the idiomatic ways in Go to judge specific error values?

Since error is an interface type, even if the error values are of the same type, their actual types may be different. This question can also be phrased differently, namely: How do we determine which type of error an error value specifically represents?

The typical answer to this question is as follows:

  1. For a series of error values with known types, type assertion expressions or type switch statements are generally used to determine.
  2. For a series of error values that already have corresponding variables and are of the same type, direct equality comparison operations are generally used to determine.
  3. For a series of error values without corresponding variables and with unknown types, we can only use the string representation of their error messages to make judgments.

Problem Analysis #

If you have read the source code of some Go standard library, you should be familiar with these situations. I will explain them below.

It is actually the easiest to distinguish error values within a known range. Take the several error types in the os package, os.PathError, os.LinkError, os.SyscallError, and os/exec.Error, as examples. Their pointer types are all implementation types of the error interface, and they all have a field named Err, which represents a potential error and has the type error interface.

If we get an error value of type error and know that its actual type must be one of them, we can use a type switch statement to determine it. For example:

func underlyingError(err error) error {
    switch err := err.(type) {
    case *os.PathError:
        return err.Err
    case *os.LinkError:
        return err.Err
    case *os.SyscallError:
        return err.Err
    case *exec.Error:
        return err.Err
    }
    return err
}

The purpose of the underlyingError function is to obtain and return the potential error value of known operating system-related errors. The type switch statement has several case clauses, corresponding to the above error types. When they are selected, the Err field of the function parameter err is returned as the result value. If none of them are selected, the function directly returns the parameter value as the result, thus giving up obtaining the potential error value.

We can do this as long as the types are different. However, in the case of error values with the same type, these methods are powerless. In the Go standard library, there are also many errors of the same type created in the same way.

Let’s take the os package as an example. Many error values in it are initialized by calling the errors.New function, such as os.ErrClosed, os.ErrInvalid, and os.ErrPermission, and so on.

Note that, unlike the aforementioned error types, these are already defined and specific error values. In the os package, these values are sometimes treated as potential error values and encapsulated into the values of the aforementioned error types.

If we obtain an error value when operating the file system and know that its potential error value must be one of the above values, we can use a regular switch statement to determine it. Of course, using an if statement and the equality operator is also possible. For example:

printError := func(i int, err error) {
    if err == nil {
        fmt.Println("nil error")
        return
    }
    err = underlyingError(err)
    switch err {
    case os.ErrClosed:
        fmt.Printf("error(closed)[%d]: %s\n", i, err)
    case os.ErrInvalid:
        fmt.Printf("error(invalid)[%d]: %s\n", i, err)
    case os.ErrPermission:
        fmt.Printf("error(permission)[%d]: %s\n", i, err)
    }
}

This function represented by the printError variable accepts an argument value of type error. The value always represents an error related to a file operation, which I deliberately obtained by operating the file in an incorrect way.

Although I do not know the range of these error values, I know that they or their potential error values must be some values defined in the os package.

Therefore, I first use the underlyingError function to obtain their potential error values, but it may also only obtain the original error value. Then, I use a switch statement to perform an equality check on the error value. The three case clauses correspond to the three aforementioned error values that already exist in the os package. In this way, I can distinguish the specific error.

For the above two situations, we have explicit ways to solve them. However, if we know very little about the meaning that an error value may represent, we can only make judgments based on the error information it holds.

Fortunately, we can always get the error information of an error value through its Error method. In fact, the os package also has functions that do this kind of judgment, such as os.IsExist, os.IsNotExist, and os.IsPermission. The source code file demo45.go includes applications of them, which are similar to the code shown above, so I won’t repeat them here.

Summary #

Today we have collectively learned the basics of error handling. We have summarized the types of errors, techniques for handling error values, and design patterns. We have also shared the most fundamental way of handling errors in the Go language. As the topic of error handling is divided into two parts, in the next article, we will approach it from a builder’s perspective and explore how to provide appropriate error values based on specific situations.

Thought Question #

Please list three types of errors that you frequently encounter or see, and describe the error classification system to which they belong. Can you draw a tree to depict them?

Thank you for listening. See you in the next episode.

Click here to view the detailed code accompanying the Go Language column article.