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:
- For a series of error values with known types, type assertion expressions or type
switch
statements are generally used to determine. - 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.
- 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.