Go, One Year Later

Author: Alexander Avery

Posted: Mon | Mar 21, 2022

computer-science A pocket watch suspended above a pathway covered in fallen leaves.

Three years of Go, one year of rigorous use. What’s the lesson?

When I began learning Go, I generally avoided languages that felt different from what I knew. I had mainly been developing in Python and Java, and the lack of Exceptions in Go stopped me from giving it a serious attempt for years. In a completely expected 180, after 4 years of using Go, it’s style of error handling has become one of my favorite features. In this article, I want to share several of my other favorite things I’ve been taught while practicing with Go.

Program with Errors

Let’s cover this in detail. Many months ago, I wrote some brief notes on error handling in Go and Rust. At the time, I had heard people encourage Go developers to “program with errors” not just return them, but I did not have any good ideas as to how this could be done.

I was starting to appreciate the simplicity of the Error interface, but hadn’t proven to myself that it could be used in a variety of ways. A fantastic starting point from the Go standard library is the Scanner struct. Its work is done with Scanner.Scan, which can encounter errors, though it does not return them. Instead, it will return false when it has encountered an error, or has exhausted the reader it is scanning.

If the Scanner encountered an error, it will later be accessible by calling Scanner.Err(). The difference is subtle, but allows the caller to produce a much more readable program.

Here is how you can use the Scanner from the standard library:

s := NewScanner(reader)
for s.Scan() {
  token := s.Text()
  // do stuff with the token
}
		
if err := s.Err(); err != nil {
	return err
}
// continue the program

Next, let’s look at an alternative Scanner API:

s := NewMediocreScanner(reader)
var err Error;
for err = s.Scan() {
  if err != nil {
    return err
  }
  token = s.Text()
  // do stuff with the token
}

These examples are similar, but the difference is important. The former example adds two separate scopes to the program. The first scope is inside the loop, and the second comes later when we check if the scanner has encountered an error.

The latter example adds two nested scopes. It is my opinion that the non-nested scopes are easier to read, and therefore easier to maintain. This example is basic, but in a real program we may already be in multiple nested scopes before we create the scanner.

There are other ways of organizing the MediocreScanner example, but if no error occurs, how will we know scanning is complete? We must check that as well, if we want to exit the loop properly. If instead we return a special error upon completion, we may need to handle it separately from normal errors, so it requires another check. In any case, we generally shouldn’t use an error to signal things that aren’t actually errors.

Perhaps MediocreScanner.Scan() could return both a boolean and an error at once. But that would end up returning redundant information. Whenever an error returns, our boolean should also signify that scanning is complete, so we don’t actually need both.

After all that, I’m sure we can agree that the standard library has a great Scanner implementation. What then can we learn about error handling in general?

What it shows me is that you don’t always need to handle errors immediately. Sometimes it’s effective to design your API, so it can complete its main purpose gracefully. And since Go allows you to program with errors, they can stand aside while your program does its job.

Full disclaimer, I’ve written code just like the MediocreScanner above, and likely will again if I’m not careful.

Similarities to languages with ‘Async/Await’

Even in other languages, you may come across similar reactionary patterns. If you’ve worked with asynchronous code in C#, JavaScript, or Dart, how many times have you seen a function like the following?

Future<dynamic> myAsyncFunction() async {
	// things happening in the function
	return await hisAsyncFunction(someLocalVariable);
}

In the above snippet, you don’t actually need to call await, so why is it so common to see this happen? I believe it’s because ‘awaiting’ async functions is habitual, as is returning errors immediately.

To restate the main point as it relates to Go: It’s not always best to return errors immediately from where they occur. Because errors are just values, Go gives you the power to handle them gracefully without requiring you to structure all of your code around them. Instead, you may structure your errors so that they fit well with your program’s intended operation.

Interfaces

I’ll keep this part short because I want to expand on interfaces in a different post. Between projects, I’ve seen great and poor usage of interfaces, but the best advice I’ve heard is to write code rather than design types. This advice has been given in relation to generics in go 1.18, but it applies to interfaces as well. Unless you have an obvious need, it’s better to write working code first and later discover the interfaces that exist in your program. This was actually how the beloved io.Reader interface was created as well.

Find and Use Great Tools

I wouldn’t be the first person to point out that the tools that come with Go are fantastic. There are also a few third-party tools that facilitate important development tasks with minimal friction. After relying on these for years, I keep watchful for comparable programs designed for other languages I use.

go test

This built-in program allows you to run tests and benchmarks in a Go package. It’s very fast to startup and has excellent built in visualizations for things like code coverage. Testing is a crucial to my work, and go test makes local testing and pipeline tests easy and boring. Despite its simplicity, it also has a comprehensive set of features.

gofmt

The default execution of gofmt is remarkable for keeping code style consistent, but I rarely see people appreciate its rewrite rules. With the gofmt rewrite rules, you can describe alterations to make to your program with a domain specific language.

To make the transformation from bytes.Compare to bytes.Equal across all files:

gofmt -r 'bytes.Compare(a, b) == 0 -> bytes.Equal(a, b)' *go

What’s even better is that all lowercase, single-character identifiers act as wildcards. So, to replace any call of toString() with ToString() across your whole program:

gofmt -r 'a.toString() -> a.ToString()' *go

I’ve used that many times to change a public method to private, or vice versa.

One level deeper you can find gofix which uses rewrite rules to make incredibly valuable source code changes. Imagine making breaking changes to an API, and simply giving everyone in your company a handful of rewrite rules to run. Problem solved.

pprof

If you aren’t committing a cardinal sin of computer programming, you rarely reach for profiling tools first. But the last thing you want is to have crummy options when you desperately need to make optimizations.

Pprof can profile your entire application, or you can begin the profile at specific points in your program. Many popular languages have nothing comparable for taking specific profiles, and it’s a nightmare to be up that river without a paddle. Don’t get me wrong, you can get specific profiles in other languages, but for the popular ones it’s the norm to tie you to a specific IDE or environment. The visualizations of pprof are also easy to navigate, and it’s not so complicated that I have to relearn the process every few months.

Delve

There isn’t much to say about delve that you wouldn’t expect from a debugger, but it’s an essential I should link.

Explore Languages with Meaningful Differences

Heaps of computer languages have superficial differences, but the ones with unique design goals will teach you the most. Finally picking up Go after being wary of its differences was a necessary step to take before exploring more varied languages. Since then, I’ve reached searched out others in an attempt to change the way I think about solving different problems on the computer.

Here are some I’ve taken up at a beginner level:

Something I’d like to explore next is logic programming with Prolog. There is even an embedded implementation for Go, for which I have really ambitious ideas.

I’d like to remark more on the fact that SQL is a part of the above list. I used SQL for years, but until I experienced the benefits of languages built for their domain, I had only known it through the filter of an ORM. That is surprisingly limiting in hindsight, and I hope that these reflections might encourage any of you to step out of your comfort zone.

Next: Ruby Footguns
Previous: My Favorite ECS Game Engines
>> Home