banging errors in go
One of the many problems with programming in go is there’s functions, and the functions are written by people, and the people make mistakes, and the functions return errors, and now you have to check for the errors. This is all very tedious and tiresome. We can’t fix the people who cherish their imperfections as a sign of humanity, but we can change go to pretend the errors aren’t there.
A short experiment to write a tool to bang away the errors. Mostly to poke around and manipulate the AST some more.
error time
We’ve got a simple function, but it’s more than half errors. Why would anyone write such problematic code?
func decomp(filename string) ([]byte, error) {
fd, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fd.Close()
zd, err := gzip.NewReader(fd)
if err != nil {
return nil, err
}
data, err := io.ReadAll(zd)
if err != nil {
return nil, err
}
return data, nil
}
What does this function even do if there’s an error? I have to read so much code to find out. Wouldn’t it be cleaner if we could write lines like this?
data := io.ReadAll(zd)!
Now it’s obvious to everyone exactly what happens. All we need is a little tool to parse this code, rewrite it to add the err checks, and then send it to the real go compiler.
banging
Except that’s not valid go syntax and won’t parse, and we’re not changing the parser for a fun toy. But this will.
data := !io.ReadAll(zd)
I actually prefer this. When reading code, I tend to focus on the left side, around the assignment. That feels like the important part, and then I stop scanning before I get all the way to the end.
The catch is that if we use this particular operator, we won’t be able to call a boolean function and reverse it and assign it. I never seem to do this? I call !fn() in conditionals, and I assign boolean functions to variables, but not both at the same time. Nevertheless, we can avoid the conflict by selecting another operator.
data := ^io.ReadAll(zd)
I quite like this. I know that I’ve never xored a function return in an assignment, although maybe there’s some field where that’s really common.
The caret also reminds me of a footnote, which seems appropriate. There’s a little extra happening here, but it’s not really important, no need to bore you with the details.
The final function looks like this.
func decomp(filename string) ([]byte, error) {
fd := ^os.Open(filename)
defer fd.Close()
zd := ^gzip.NewReader(fd)
data := ^io.ReadAll(zd)
return data, nil
}
All neat and tidy. Out of sight and out of mind.
bango
The internal operation of bango is straightforward. Scan through the AST looking for statements, then check that left and right sides have one expression, then check that right side is a unary xor operator applied to a call expression. Then we conjure up an if statement with an error check and return statement, and insert that into the block after the assignment.
The reverse operation is similar. Look for error checking statements after function calls, then remove them and add an xor to the function. Everything is so easy now that there’s functions to insert and remove from a slice.
Now that I have a little better understanding of the go AST structures, it was mostly just a matter of typing out what I wanted, then iterating until I was happy with the results.
matching
Straightforward, but still tedious. The old code was not pretty.
you may not like it, but this is ideal error checking
next := block.List[i+1]
iff, ok := next.(*ast.IfStmt)
if !ok {
continue
}
cond, ok := iff.Cond.(*ast.BinaryExpr)
if !ok || cond.Op != token.NEQ {
continue
}
x, _ := cond.X.(*ast.Ident)
y, _ := cond.Y.(*ast.Ident)
if x == nil || x.Name != "err" || y == nil || y.Name != "nil" {
continue
}
body := iff.Body
if len(body.List) != 1 {
continue
}
ret, ok := body.List[0].(*ast.ReturnStmt)
if !ok {
continue
}
res := ret.Results
if len(res) != 2 {
continue
}
x, _ = res[0].(*ast.Ident)
y, _ = res[1].(*ast.Ident)
if x == nil || x.Name != "nil" || y == nil || y.Name != "err" {
continue
}
Adding a little pattern matcher helps. New code looks a lot cleaner.
matcher2 := match.If(match.Binary(match.Ident("err"), token.NEQ, match.Ident("nil")),
match.Block(match.Multi(match.Return(match.Multi(match.Ident("nil"), match.Ident("err"))))))
Could be useful for future toys. So many operators still to choose from. Can we automatically insert defer statements?
wrapping
One might also want to wrap the bubbles.
func decomp(filename string) ([]byte, error) {
fd, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("decomp os.Open error: %w", err)
}
defer fd.Close()
zd, err := gzip.NewReader(fd)
if err != nil {
return nil, fmt.Errorf("decomp gzip.NewReader error: %w", err)
}
data, err := io.ReadAll(zd)
if err != nil {
return nil, fmt.Errorf("decomp io.ReadAll error: %w", err)
}
return data, nil
}