Monday, April 25, 2022

Go vs Rust - Error Handling

 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:

type error 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:

package main
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:

package main
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:

package main
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.

package main
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.

func FindUser(username string) (*db.User, error) {
    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)
2 Rust

Rust groups errors into two major categories: recoverable and unrecoverable errors.Rust doesn’t have exceptions. Instead, it has the type Result<T, E> for recoverable errors and the panic! macro that stops execution when the program encounters an unrecoverable error. 

Panic Macro and Unrecoverable Errors

panic! macro allows a program to terminate immediately and provide feedback to the caller of the program. It should be used when a program reaches an unrecoverable state.

fn main() {
   panic!("Hello");
   println!("End of main"); //unreachable statement
}

Unwrap

Expects self to be Ok/Some and returns the value contained within. If it is Err or None instead, it raises a panic with the contents of the error displayed.

fn main(){
   let result = is_even(10).unwrap();
   println!("result is {}",result);
}
fn is_even(no:i32)->Result<bool,String> {
   if no % 2 == 0 {
      return Ok(true);
   } else {
      return Err("NOT_AN_EVEN".to_string());
   }
}

or

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);
        }
    });
}

Expect

Behaves like unwrap, except that it outputs a custom message before panicking in addition to the contents of the error.

use std::fs::File;
fn main(){
   let f = File::open("pqr.txt").expect("File not able to open");
   //file does not exist
   println!("end of main");
}

Propagating Errors
When a function’s implementation calls something that might fail, instead of handling the error within the function itself, you can return the error to the calling code so that it can decide what to do.

Result Enum and Recoverable Errors

Enum Result – <T,E> can be used to handle recoverable errors. It has two variants − OK and Err. T and E are generic type parameters. T represents the type of the value that will be returned in a success case within the OK variant, and E represents the type of the error that will be returned in a failure case within the Err variant.

enum Result<T,E> {
   OK(T),
   Err(E)
}

use std::fs::File;
fn main() {
   let f = File::open("main.jpg");   // main.jpg doesn't exist
   match f {
      Ok(f)=> {
         println!("file found {:?}",f);
      },
      Err(e)=> {
         println!("file not found \n{:?}",e);   //handled error
      }
   }
   println!("end of main");
}

Aliases for Result

type Result<T> = Result<T, std::io::Error>;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

A Shortcut for Propagating Errors: the ? Operator

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)
}

Defining an error type

Sometimes it simplifies the code to mask all of the different errors with a single type of error.

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