Go 2 aims to improve the overhead of error handling, but do you know what is better than an improved syntax for handling errors? Not needing to handle errors at all. Now, I’m not saying “delete your error handling code”, instead I’m suggesting changing your code so you don’t have as many errors to handle.
This article draws inspiration from a chapter in John Ousterhout’s, A philosophy of Software Design, “Define Errors Out of Existence”. I’m going to try to apply his advice to Go.
Here’s a function to count the number of lines in a file,
func CountLines(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)
for {
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
}
if err != io.EOF {
return 0, err
}
return lines, nil
}
We construct a bufio.Reader
, then sit in a loop calling the ReadString
method, incrementing a counter until we reach the end of the file, then we return the number of lines read. That’s the code we wanted to write, instead CountLines
is made more complicated by its error handling. For example, there is this strange construction:
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
We increment the count of lines before checking the error—that looks odd. The reason we have to write it this way is ReadString
will return an error if it encounters an end-of-file—io.EOF
—before hitting a newline character. This can happen if there is no trailing newline.
To address this problem, we rearrange the logic to increment the line count, then see if we need to exit the loop.
But we’re not done checking errors yet. ReadString
will return io.EOF
when it hits the end of the file. This is expected, ReadString
needs some way of saying stop, there is nothing more to read. So before we return the error to the caller of CountLine
, we need to check if the error was not io.EOF
, and in that case propagate it up, otherwise we return nil
to say that everything worked fine. This is why the final line of the function is not simply
return lines, err
I think this is a good example of Russ Cox’s observation that error handling can obscure the operation of the function. Let’s look at an improved version.
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
This improved version switches from using bufio.Reader
to bufio.Scanner
. Under the hood bufio.Scanner
uses bufio.Reader
adding a layer of abstraction which helps remove the error handling which obscured the operation of our previous version of CountLines
The method sc.Scan()
returns true
if the scanner has matched a line of text and has not encountered an error. So, the body of our for
loop will be called only when there is a line of text in the scanner’s buffer. This means our revised CountLines
correctly handles the case where there is no trailing newline, It also correctly handles the case where the file is empty.
Secondly, as sc.Scan
returns false
once an error is encountered, our for
loop will exit when the end-of-file is reached or an error is encountered. The bufio.Scanner
type memoises the first error it encounters and we recover that error once we’ve exited the loop using the sc.Err()
method.
Lastly, buffo.Scanner
takes care of handling io.EOF
and will convert it to a nil
if the end of file was reached without encountering another error.
My second example is inspired by Rob Pikes’ Errors are values blog post.
When dealing with opening, writing and closing files, the error handling is present but not overwhelming as, the operations can be encapsulated in helpers like ioutil.ReadFile
and ioutil.WriteFile
. However, when dealing with low level network protocols it often becomes necessary to build the response directly using I/O primitives, thus the error handling can become repetitive. Consider this fragment of a HTTP server which is constructing a HTTP/1.1 response.
type Header struct {
Key, Value string
}
type Status struct {
Code int
Reason string
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
if err != nil {
return err
}
for _, h := range headers {
_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}
if _, err := fmt.Fprint(w, "\r\n"); err != nil {
return err
}
_, err = io.Copy(w, body)
return err
}
First we construct the status line using fmt.Fprintf
, and check the error. Then for each header we write the header key and value, checking the error each time. Lastly we terminate the header section with an additional \r\n
, check the error, and copy the response body to the client. Finally, although we don’t need to check the error from io.Copy
, we do need to translate it from the two return value form that io.Copy
returns into the single return value that WriteResponse
expects.
Not only is this a lot of repetitive work, each operation—fundamentally writing bytes to an io.Writer
—has a different form of error handling. But we can make it easier on ourselves by introducing a small wrapper type.
type errWriter struct {
io.Writer
err error
}
func (e *errWriter) Write(buf []byte) (int, error) {
if e.err != nil {
return 0, e.err
}
var n int
n, e.err = e.Writer.Write(buf)
return n, nil
}
errWriter
fulfils the io.Writer
contract so it can be used to wrap an existing io.Writer
. errWriter
passes writes through to its underlying writer until an error is detected. From that point on, it discards any writes and returns the previous error.
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
ew := &errWriter{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
for _, h := range headers {
fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
}
fmt.Fprint(ew, "\r\n")
io.Copy(ew, body)
return ew.err
}
Applying errWriter
to WriteResponse
dramatically improves the clarity of the code. Each of the operations no longer needs to bracket itself with an error check. Reporting the error is moved to the end of the function by inspecting the ew.err
field, avoiding the annoying translation from io.Copy
’s return values.
When you find yourself faced with overbearing error handling, try to extract some of the operations into a helper type.