I Love Errors
Author: Alexander Avery
Posted: Wed | Sep 28, 2022
computer-scienceI LOVE ERRORS. I LOVE ERRORS. I LOVE ERRORS.
Seriously, I do. In an environment where errors are inevitable, how do we embrace them? Errors permeate every part of our programs, but usually this isn’t easy to notice. Often, potential errors hide in our programs, and leap out at inopportune moments during runtime.
Provided we cannot eliminate errors, it would be great to know exactly when they can happen, and make guarantees about our programs. We want to avoid hiding errors with syntax and language rules if these practices confuse our understanding of a program’s execution. Luckily this is simple, and it can be done in any language, but is only considered idiomatic in a handful of popular ones.
Errors and Exceptions (are not the same)
I’m going to demonstrate why I love error handling in Go, and allow the reader to extrapolate this to other languages.
For the following examples, we’re ignoring goto
and panic
.
Although both of those keywords are found in the standard library, they are rarely considered idiomatic Go.
Idiomatic error handling in C, Scala, and Rust are all different, but share qualities I like from the Go model.
Many people are familiar with exceptions, but if you have not seen error handling outside that realm, here are a few resources:
Example 1
To begin, we are going to compare the same program written in Go and Python. No hate towards snek_language, it’s only chosen because it supports exceptions, and is easy to write. Our program needs to call a function from an external package, print the result, then write the result to a file. In practice, a program this small doesn’t need much error handling, but nonetheless we will use it as an example.
First, let’s take a look at our program in Python.
import external as e
result = e.Calculate(42, "Hello World")
print(f"Writing {result} to output.txt")
f = open("output.txt", "w")
f.write(result)
f.close()
Now, imagine how this program will run line by line. One may argue that in prose, the program will complete the following steps:
- Import a package.
- Make a calculation and store the result in
result
. - Print the result for a human to read on the console.
- Write the result to a file.
- Close the file.
In most cases, they would be right. Now let us take a look at the same program in Go.
package main
import (
"beetbox.io/external"
"fmt"
"log"
"os"
)
func main() {
result, err := external.Calculate(42, "Hello World")
if err != nil {
log.Fatal("Failed to make calculation %v", err)
}
fmt.Printf("Writing %s to output.txt", result)
f, err := os.Create("output.txt")
if err != nil {
log.Fatal("Failed to create output file %v", err)
}
_, err = fmt.Fprint(f, result)
if err != nil {
log.Print("Failed to write result to file %v", err)
}
err = f.Close()
if err != nil {
log.Fatal("Failed to close file %v", err)
}
}
Okay, for anyone out there who is not a fan of this error handling, I’ll say it for you: “GOOD GRIEF”. And with that out of the way, I’d like to talk about why I am a fan.
The Go program exposes a multitude of errors that we could encounter in the Python program.
Any line in our Python program could throw an exception.
So at runtime when e.Calculate
runs, we have zero guarantees print
will run next.
We actually have no guarantees that we will run e.Calculate
at all, since importing modules can run arbitrary code, and therefore throw exceptions.
In our Go program, even with basic error handling, the if
flow control makes it clear which route our program will take.
Remember, errors are values.
In that light, let’s take a look at one more example, that doesn’t have potential danger at each step.
Example 2
Our next snippet defines a widget that takes a list of floats, and performs various operations on them before returning the results. In Python, we’ll handle issues with an exception, and if you spot the mistake, you get a high-five. I have seen and written code with this mistake before, so if you don’t spot it right away, welcome to the club.
class Widget:
def __init__(self):
self.whirrring = False
def whirrr(self, the_floats):
# note we are starting to whirrr
# we want to unset this upon completion
self.whirrring = True
values = list()
for f in the_floats:
# 0 is our special signal to stop calculating
if f == 0:
break
f += 15
f /= 9
if f > 100:
raise Exception("Critical error, created value greater than 100")
values.append(f)
self.whirrring = False
return values
The snippet in Go will perform the same calculations, but handle errors differently. Now, let’s look at the Go snippet. It will perform the same calculations, but error handling takes a different idiomatic strategy.
package main
import "errors"
type widget struct{
errors []error
whirrring bool
}
func (w *widget) Whirrr(floats []float) []float {
// note we are starting to whirrr
// we want to unset this upon completion
w.whirrring = true
values := make([]float)
for _, f := range floats {
// 0 is our special signal to stop calculating
if f == 0 {
break
}
f += 15
f /= 9
if f > 100 {
w.errors = append(w.errors, errors.New("Created value greater than 100 with %f", f))
}
values = append(values, f)
}
w.whirrring = false
return values
}
func (w *widget) Errors() []error {
return w.errors
}
How to tell your program works by looking at it
Now, let’s talk about those guarantees again.
The issue in our Python program is that whirrring
does not get properly reset if we raise an exception.
If we returned the error early in Go, we might encounter the same problem.
But it is my opinion that a return statement makes it more obvious we should carefully close out the method call.
It also prevents other methods we call from prematurely and unexpectedly halting our method. Imagine if all the operations on the floats were done in another package. It would be much more difficult to spot that your method could return early, and fail to do cleanup.
This could be handled with defer
or try
, so it’s not an inevitable issue of either language.
Regardless, we can see how certain language constructs might set up unexpected traps.
In programs with very few errors, we can find clear benefits.
Does the following Python program assign a
, then b
, then c
properly?
I’m honestly not sure, so I’ll have to find out if those methods can throw.
If those methods call other methods, I’ll have to make sure those don’t throw either.
# some code ...
a = do_something()
b = a * external_method()
c = external_result(a, b)
# program continues ...
In the following Go program, we know a
will be assigned, then b
, then c
, just by reading it.
The functions could panic
, but this is discouraged.
Since we trust a majority of methods don’t panic
, our programs will execute exactly as they appear to.
// some code ...
a := doSomething()
b := a * externalMethod()
c := externalResult(a, b)
// program continues ...
Next: Languages Blocking Language
Previous: Open Source Without GitHub
>> Home