1 Go
Error handling in Go is a little different than other mainstream programming languages like Java, JavaScript, or Python. Go’s built-in errors don’t contain stack traces, nor do they support conventional try/catch methods to handle them. Instead, errors in Go are just values returned by functions, and they can be treated in much the same way as any other datatype - leading to a surprisingly lightweight and simple design.
The Error Type
The error type in Go is implemented as the following interface:
Error() string
}
So basically, an error is anything that implements the Error() method, which returns an error message as a string. It’s that simple!
Constructing Errors
Errors can be constructed on the fly using Go’s built-in errors or fmt packages. For example, the following function uses the errors package to return a new error with a static error message:
import "errors"
func DoSomething() error {
return errors.New("something didn't work")
}
Similarly, the fmt package can be used to add dynamic data to the error, such as an int, string, or another error. For example:
import "fmt"
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("can't divide '%d' by zero", a)
}
return a / b, nil
}
Note that fmt.Errorf will prove extremely useful when used to wrap another error with the %w format verb.
There are a few other important things to note in the example above.
- Errors can be returned as nil, and in fact, it’s the default, or “zero”, value of on error in Go. This is important since checking if err != nil is the idiomatic way to determine if an error was encountered
- Errors are typically returned as the last argument in a function. Hence in our example above, we return an int and an error, in that order.
- When we do return an error, the other arguments returned by the function are typically returned as their default “zero” value. A user of a function may expect that if a non-nil error is returned, then the other arguments returned are not relevant.
- Lastly, error messages are usually written in lower-case and don’t end in punctuation. Exceptions can be made though, for example when including a proper noun, a function name that begins with a capital letter, etc.
Defining Expected Errors
Another important technique in Go is defining expected Errors so they can be checked for explicitly in other parts of the code. This becomes useful when you need to execute a different branch of code if a certain kind of error is encountered.
Defining Sentinel Errors
Building on the Divide function from earlier, we can improve the error signaling by pre-defining a “Sentinel” error. Calling functions can explicitly check for this error using errors.Is:
import (
"errors"
"fmt"
)
var ErrDivideByZero = errors.New("divide by zero")
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
func main() {
a, b := 10, 0
result, err := Divide(a, b)
if err != nil {
switch {
case errors.Is(err, ErrDivideByZero):
fmt.Println("divide by zero error")
default:
fmt.Printf("unexpected division error: %s\n", err)
}
return
}
fmt.Printf("%d / %d = %d\n", a, b, result)
}
Defining Custom Error Types
There can be times when you might want a little more functionality. Perhaps you want an error to carry additional data fields, or maybe the error’s message should populate itself with dynamic values when it’s printed.
Below is a slight rework of the previous example. Notice the new type DivisionError, which implements the Error interface. We can make use of errors.As to check and convert from a standard error to our more specific DivisionError.
import (
"errors"
"fmt"
)
type DivisionError struct {
IntA int
IntB int
Msg string
}
func (e *DivisionError) Error() string {
return e.Msg
}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, &DivisionError{
Msg: fmt.Sprintf("cannot divide '%d' by zero", a),
IntA: a, IntB: b,
}
}
return a / b, nil
}
func main() {
a, b := 10, 0
result, err := Divide(a, b)
if err != nil {
var divErr *DivisionError
switch {
case errors.As(err, &divErr):
fmt.Printf("%d / %d is not mathematically valid: %s\n",
divErr.IntA, divErr.IntB, divErr.Error())
default:
fmt.Printf("unexpected division error: %s\n", err)
}
return
}
fmt.Printf("%d / %d = %d\n", a, b, result)
}
Wrapping Errors
Often in real-world programs, there can be many more functions involved - from the function where the error is produced, to where it is eventually handled, and any number of additional functions in-between.
Uses fmt.Errorf with a %w verb to “wrap” errors as they “bubble up” through the other function calls.
u, err := db.Find(username)
if err != nil {
return nil, fmt.Errorf("FindUser: failed executing db query: %w", err)
}
return u, nil
}
func SetUserAge(u *db.User, age int) error {
if err := db.SetAge(u, age); err != nil {
return fmt.Errorf("SetUserAge: failed executing db update: %w", err)
}
}
func FindAndSetUserAge(username string, age int) error {
var user *User
var err error
user, err = FindUser(username)
if err != nil {
return fmt.Errorf("FindAndSetUserAge: %w", err)
}
if err = SetUserAge(user, age); err != nil {
return fmt.Errorf("FindAndSetUserAge: %w", err)
}
return nil
}
func main() {
if err := FindAndSetUserAge("bob@example.com", 21); err != nil {
fmt.Println("failed finding or updating user: %s", err)
return
}
fmt.Println("successfully updated user's age")
}
If used correctly, error wrapping can provide additional context about the lineage of an error, in ways similar to a traditional stack-trace.
Wrapping also preserves the original error, which means errors.Is and errors.As continue to work, regardless of how many times an error has been wrapped. We can also call errors.Unwrap to return the previous error in the chain.
In summary, here’s the gist of what was covered here:
- Errors in Go are just lightweight pieces of data that implement the Error interface
- Predefined errors will improve signaling, allowing us to check which error occurred
- Wrap errors to add enough context to trace through function calls (similar to a stack trace)
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt").unwrap_or_else(|error| {
if error.kind() == ErrorKind::NotFound {
File::create("hello.txt").unwrap_or_else(|error| {
panic!("Problem creating the file: {:?}", error);
})
} else {
panic!("Problem opening the file: {:?}", error);
}
});
}
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}
type Result<T> = std::result::Result<T, DoubleError>;
#[derive(Debug)]
enum DoubleError {
EmptyVec,
// We will defer to the parse error implementation for their error.
// Supplying extra info requires adding more data to the type.
Parse(ParseIntError),
}
impl fmt::Display for DoubleError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
DoubleError::EmptyVec =>
write!(f, "please use a vector with at least one element"),
// The wrapped error contains additional information and is available
// via the source() method.
DoubleError::Parse(..) =>
write!(f, "the provided string could not be parsed as int"),
}
}
}
impl error::Error for DoubleError {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match *self {
DoubleError::EmptyVec => None,
// The cause is the underlying implementation error type. Is implicitly
// cast to the trait object `&error::Error`. This works because the
// underlying type already implements the `Error` trait.
DoubleError::Parse(ref e) => Some(e),
}
}
}
No comments:
Post a Comment