2, GO programming mode: error handling

Error handling has always been a problem that programming must face. If error handling is done well, the stability of the code will be very good. Different languages have different ways of processing. Go language is the same. In this article, let's discuss the error sources of go language, especially the crazy if err= nil .

In the formal discussion of Go code, the full screen of if err= Before how to deal with nil, I'd like to talk about the error handling in programming. In this way, we can understand the error handling in programming at a higher level.

Error checking of C language

First of all, we know that the most direct way to deal with errors is through error codes, which is also a traditional way. In procedural languages, errors are usually handled in this way. For example, C language basically identifies whether there is an error through the return value of the function, and then tells you why there is an error through the global errno variable and an errstr array.

Why is this design? The reason is very simple. In addition to sharing some mistakes, it is more important that this is actually a compromise. For example, the return value of read(), write(), open() functions is actually the value with business logic. In other words, the return values of these functions have two semantics. One is the successful value, such as the FILE handle pointer FILE * returned by open() or the error NULL. In this way, the caller does not know what caused the error. It needs to check errno to get the cause of the error, so that the error can be handled correctly.

Generally speaking, such error handling is not a problem in most cases. However, there are exceptions. Let's take a look at the following functions of C language:

int  atoi(const  char *str)

This function converts a string to an integer. But the problem is, if a string to be passed is illegal (not in the format of numbers), such as "ABC" or integer overflow, what should this function return? Error return. It is unreasonable to return any number, because it will be confused with normal results. For example, if you return 0, it will be completely confused with the normal return value of "0" character. In this way, it is impossible to judge the error. You may say whether you want to check errno. It should be checked by reason. However, we can see such a description in the specification of C99——

7.20.1The functions atof, atoi, atol, and atoll need not affect the value of the integer expression errno on an error. If the value of the result cannot be represented, the behavior is undeļ¬ned.

Functions such as atoi(), atof(), atol() or atoll() do not set errno. Moreover, it is also said that if the result cannot be calculated, the behavior is undefined. Therefore, later, libc gave a new function strtol(), which will set the global variable errno when an error occurs:

long val = strtol(in_str, &endptr, 10);  //10 means decimal

//If you cannot convert
if (endptr == str) {
    fprintf(stderr, "No digits were found\n");
    exit(EXIT_FAILURE);
}

//If the integer overflows
if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) {
    fprintf(stderr, "ERROR: number out of range for LONG\n");
    exit(EXIT_FAILURE);
 }

//In case of other errors
if (errno != 0 && val == 0) {
    perror("strtol");
    exit(EXIT_FAILURE);
}

Although the strtol() function solves the problem of atoi() function, we can still feel that it is not very comfortable and natural.

Because there are some problems with the error checking method of using return value + errno:

  • If a programmer is not careful, he will forget to check the return value, resulting in a Bug in the code;
  • The function interface is very impure. Normal values and error values are confused, resulting in semantic problems.

So, later, some class libraries began to distinguish such things. For example, the system call of Windows starts to use the return of HRESULT to unify the return value of error, so as to clarify whether the return value of function call is success or error. But in this way, the input and output of the function can only be completed through the parameters of the function, so there is a difference between the so-called input parameter and output parameter.

However, this makes the semantics of parameters in function access more complex. Some parameters are in parameters and some parameters are out parameters. The function interface becomes more complex. Moreover, there is still no solution to the problem that the success or failure of functions can be ignored.

Java error handling

The Java language uses try catch finally to handle errors by using exceptions. In fact, this is a big step forward compared with the error handling of C language. Throwing and catching exceptions can make our code have some advantages:

  • The semantics of function interface in input (parameter), output (return value) and error handling are relatively clear.
  • The code of normal logic can be separated from the code of error handling and resource cleaning, which improves the readability of the code.
  • Exceptions cannot be ignored (if you want to ignore, you also need to catch, which is explicit neglect).
  • In object-oriented languages (such as Java), exception is an object, so polymorphic catch can be realized.
  • Compared with nested call code, it can have a significant advantage to catch exceptions or return states. For example:
    • int x = add(a, div(b,c));
    • Pizza p = PizzaBuilder().SetSize(sz).SetPrice(p)...;

Error handling of Go language

The function of Go language supports multiple return values. Therefore, business semantics (business return value) and control semantics (error return value) can be distinguished in the return interface. Many functions of Go language will return two values: result and err, so:

  • Parameters are basically input parameters, and the return interface separates the result from the error, which makes the interface semantics of the function clear;
  • Moreover, if you want to ignore the wrong parameters in Go language, you need to explicitly ignore them with_ Such variables are ignored;
  • In addition, because the returned error is an interface (there is only one method Error(), which returns a string), you can extend the custom error handling.

In addition, if a function returns multiple different types of error s, you can also use the following method:

if err != nil {
  switch err.(type) {
    case *json.SyntaxError:
      ...
    case *ZeroDivisionError:
      ...
    case *NullPointerError:
      ...
    default:
      ...
  }
}

We can see that the error handling method of Go language is essentially return value checking, but it also takes into account some benefits of exceptions - the extension of errors.

Resource cleanup

Resource cleaning is required after an error. Different programming languages have different programming modes of resource cleaning:

  • C language – goto fail is used; Go to a centralized place to clean up (an interesting article can be read)< Thought of by Apple's low-level BUG>)
  • C + + language - generally used RAII mode , through the object-oriented proxy mode, give the resources to be cleaned up to a proxy class, and then solve it in the destructor.
  • Java language - can be cleaned up in finally statement blocks.
  • Go language – use the defer keyword to clean up.

The following is an example of resource cleanup for Go language:

func Close(c io.Closer) {
  err := c.Close()
  if err != nil {
    log.Fatal(err)
  }
}

func main() {
  r, err := Open("a")
  if err != nil {
    log.Fatalf("error opening 'a'\n")
  }
  defer Close(r) // Use the defer keyword to close the file when the function exits.

  r, err = Open("b")
  if err != nil {
    log.Fatalf("error opening 'b'\n")
  }
  defer Close(r) // Use the defer keyword to close the file when the function exits.
}

Error Check Hell

Speaking of language, goerr= Nil code, such code can really make people write and spit. So is there any good way? Yes. Let's first look at the following crashing code.

func parse(r io.Reader) (*Point, error) {

    var p Point

    if err := binary.Read(r, binary.BigEndian, &p.Longitude); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.Latitude); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.Distance); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.ElevationGain); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.ElevationLoss); err != nil {
        return nil, err
    }
}

To solve this problem, we can use functional programming, as shown in the following code example:

func parse(r io.Reader) (*Point, error) {
    var p Point
    var err error
    read := func(data interface{}) {
        if err != nil {
            return
        }
        err = binary.Read(r, binary.BigEndian, data)
    }

    read(&p.Longitude)
    read(&p.Latitude)
    read(&p.Distance)
    read(&p.ElevationGain)
    read(&p.ElevationLoss)

    if err != nil {
        return &p, err
    }
    return &p, nil
}

We can see from the above code that we use Closure to extract the same code and redefine a function, so that a large number of if errs= Nil is very clean. But it will bring a problem, that is, there is an err variable and an internal function, which doesn't feel very clean.

So, can we make it cleaner? We start from bufio in Go language There seems to be something you can learn from scanner():

scanner := bufio.NewScanner(input)

for scanner.Scan() {
    token := scanner.Text()
    // process token
}

if err := scanner.Err(); err != nil {
    // process the error
}

In the above code, we can see that when scanner operates the underlying I/O, there is no if err in the for loop= In the case of nil, there is a scanner after exiting the loop Check of err (). It seems that the structure is used. By imitating it, we can reconstruct our code as follows:

First, define a structure and a member function

type Reader struct {
    r   io.Reader
    err error
}

func (r *Reader) read(data interface{}) {
    if r.err == nil {
        r.err = binary.Read(r.r, binary.BigEndian, data)
    }
}

Then, our code can become as follows:

func parse(input io.Reader) (*Point, error) {
    var p Point
    r := Reader{r: input}

    r.read(&p.Longitude)
    r.read(&p.Latitude)
    r.read(&p.Distance)
    r.read(&p.ElevationGain)
    r.read(&p.ElevationLoss)

    if r.err != nil {
        return nil, r.err
    }

    return &p, nil
}

With the above technology, our "streaming interface" is easy to handle. As follows:

package main

import (
  "bytes"
  "encoding/binary"
  "fmt"
)

// The length is not enough. One Weight is missing
var b = []byte {0x48, 0x61, 0x6f, 0x20, 0x43, 0x68, 0x65, 0x6e, 0x00, 0x00, 0x2c} 
var r = bytes.NewReader(b)

type Person struct {
  Name [10]byte
  Age uint8
  Weight uint8
  err error
}
func (p *Person) read(data interface{}) {
  if p.err == nil {
    p.err = binary.Read(r, binary.BigEndian, data)
  }
}

func (p *Person) ReadName() *Person {
  p.read(&p.Name) 
  return p
}
func (p *Person) ReadAge() *Person {
  p.read(&p.Age) 
  return p
}
func (p *Person) ReadWeight() *Person {
  p.read(&p.Weight) 
  return p
}
func (p *Person) Print() *Person {
  if p.err == nil {
    fmt.Printf("Name=%s, Age=%d, Weight=%d\n",p.Name, p.Age, p.Weight)
  }
  return p
}

func main() {   
  p := Person{}
  p.ReadName().ReadAge().ReadWeight().Print()
  fmt.Println(p.err)  // EOF error
}

I believe you should understand this skill, but its use scenario can only simplify error handling under the continuous operation of the same business object. For multiple business objects, you still need various if errs= Nil way.

Packaging error

Finally, one more word, we need to wrap the error instead of returning err to the upper layer. We need to add some execution context.

Generally speaking, we use FMT Errorf() to do this, for example:

if err != nil {
   return fmt.Errorf("something failed: %v", err)
}

In addition, among the developers of Go language, it is more common to wrap the error in another error while retaining the original content:

type authorizationError struct {
    operation string
    err error   // original error
}

func (e *authorizationError) Error() string {
    return fmt.Sprintf("authorization failed during %s: %v", e.operation, e.err)
}

Of course, a better way is to use a standard access method. In this way, we'd better use an interface, such as implementing the Cause() method in the user interface, to expose the original error for further inspection:

type causer interface {
    Cause() error
}

func (e *authorizationError) Cause() error {
    return e.err
}

The good news here is that there is no need to write such code. There is a third-party error library( github.com/pkg/errors ), for this library, I can see its existence wherever I go, so this is basically the de facto standard. Code examples are as follows:

import "github.com/pkg/errors"

//Wrong packaging
if err != nil {
    return errors.Wrap(err, "read failed")
}

// Cause interface
switch err := errors.Cause(err).(type) {
case *MyError:
    // handle specifically
default:
    // unknown error
}

This article is not written by myself. It reprints the left ear mouse blog and its source Cool shell – CoolShell

Keywords: Go

Added by meepokman on Sun, 06 Feb 2022 20:25:20 +0200