Zen Mind, Google Intern’s Mind

Notes about Go

jorge started as a Go learning project, so writing down my thoughts on the language seemed like a good way to wrap up this devlog.

I emphasize that these are first impressions more than truths or strong opinions, from using the language on a short solo project. I had high expectations going in, and for the most part, they were either confirmed or at least not disproven1.

Modules

I started my tour by reading some tutorials on the Go website. They gave me the impression that perhaps for historical reasons, module management is nuanced, at least when compared to other languages I’m familiar with. The first thing I had to adjust to was that, in Go, a module is more or less what I would call a project in other languages. And what I would call a module elsewhere —a namespace, typically contained in a single file— didn’t have an exact equivalent: each Go package is a collection of source files sharing a namespace.

One thing that Go nails down just right in this department is forcing imported identifiers to be qualified by their package name. Qualified imports make code more readable because you can tell, at a glance, where identifiers come from, how much you depend on other packages, etc. And it lets you choose clearer names, e.g. bufio.Reader and strings.Reader instead of BufferReader and StringReader. This is supported in other languages but not mandatory, or the default, or even convenient2. For similar reasons, I don’t like that files within a package share the namespace, since it makes it less obvious where local identifiers are defined, when a namespace gets too large, etc. At first, the inertia I carried from other languages made me want to write single-file packages —site/site.go, config/config.go, templates/templates.go, etc.—, which resulted in an awkward project layout.

Errors

I obviously have things to say about Go’s controversial error handling. As a recap: errors are values in Go so, instead of raising exceptions, functions signal error conditions by returning extra values. Whereas in Python I would write:

try:
    f = open('file.txt')
except OSError as err:
    print("something went wrong", err)

In Go I write:

file, err := os.Open("file.txt")
if err != nil {
	fmt.Println("something went wrong", err)
}

Not a big difference. The problem is that, in Go, the error-handling boilerplate (the if err != nil check) grows linearly with the number of operations that can fail. As an example, here’s a file-copying routine I had to introduce early in my project:

func copyFile(source string, target string) error {
	srcFile, err := os.Open(source)
	if err != nil {
		return err
	}
	defer srcFile.Close()

	targetFile, err := os.Create(target)
	if err != nil {
		return err
	}
	defer targetFile.Close()

	_, err = io.Copy(targetFile, srcFile)
	if err != nil {
		return err
	}

	return targetFile.Sync()
}

The code above doesn’t do anything special with errors, just returns them, and yet more than half of the lines are for error handling. The equivalent Python function would have the same behavior without any error handling at all or, if I wanted it to be extra explicit, with a single try/except clause.

I think the rationale behind languages with unchecked exceptions, like Python, is: 95% of the time you don’t want to manipulate errors locally, you just want to bubble them up and deal with them generically at a higher layer. So they make that the default3. Go’s rationale, in turn, is: when programmers are not forced to handle errors explicitly, they only consider the happy path; 95% of the time they won’t think hard enough about error conditions, maybe they won’t handle errors at all, which results in brittle software.

I think both views have their merit, and there’s a trade-off to be made here. I had used Rust for some time before Go, so errors as values weren’t new to me; I was already sold on the idea: they make you reason about failure scenarios from the beginning. But Rust supports this model with syntax short-hands for explicitly bubbling up errors, wrapping them in other errors, or unwrapping them into run-time checks. In the file copy example above, Rust’s question mark operator would remove all the boilerplate while remaining explicit about error handling.

Go doesn’t provide similar syntax support; you are almost always forced to do the check-nil-or-return dance —and this is in a language that does indulge in “special-case” syntax for convenience or to work around other limitations4. The problem isn’t having to write more5: the proliferation of if err != nil checks hurts readability; when looking at the code, sometimes it is convenient to focus on the happy path to understand what its author intended to do.

There’s an official article by Rob Pike that discusses this issue, saying that if you find yourself typing err != nil repeatedly, you probably aren’t trying hard enough. Then it demonstrates some creative ways to reduce error-handling boilerplate. In my (very short) experience, I’ve found that the code structure doesn’t always leave room for such refactors; that aside, I think something as mundane as error handling shouldn’t need you to get creative. Custom error-checking helpers and clever APIs like the bufio.Scanner one mentioned in the article, can too hurt readability and even defeat the initial purpose of forcing all errors to be checked explicitly.

Another post argues that perhaps code boilerplate isn’t always that bad; that we can think of it as representing the “substance” of an operation. What I found thought-provoking in this argument was the idea that the succinctness of Python code —the beautiful is better than ugly mantra— could make us inclined to postpone the introduction of necessary boilerplate, for example, error-handling boilerplate: that elegance can inadvertently become an end in itself. This left me thinking: what if the error-checking discipline buys us a reliability that outweighs its readability cost? Who could tell which of the two has a bigger impact on software maintainability? How much of our strongest convictions are founded on mere gut feeling?

Expressiveness

The error handling discussion can be thought of as a particular case of a broader one, that of language expressiveness. Expressiveness is a vaguely-defined and highly subjective quality6 but it can serve to compare programming languages. I think about expressiveness as the distance a language puts between the abstract idea of a task —in pseudocode, perhaps— and its working implementation. I don’t just mean distance in terms of lines of code —how verbose the language is— but the cognitive effort it takes to arrive at a working solution —how much the language helps, how much it gets in the way.

  • I find Python to be a highly expressive language. Perhaps because it’s close to pseudocode; perhaps because I used it long enough that my mental pseudocode is close to Python. As seen in the error handling discussion, more expressive isn’t necessarily better.
  • Rust tends to be on the opposite end. Working code (or subsets of it) may be elegant and succinct, but arriving at it can be a struggle, even for mundane tasks like iterating and transforming data structures.
  • I would say that Go is somewhere in between Python and Rust. It’s verbose, even bureaucratic; while its feature set is small, it’s full of little syntax and design quirks. But, unlike Rust, I rarely find myself baffled, stuck trying to make a piece of code compile or fit in the programming model.

Go was designed to err on the side of simplicity, in some cases removing choice from the programmer to prevent misuse, like a style guide baked directly into a language. It leaves no room for enamoring yourself with an elegant piece of code, a pristine type family, or an overarching class hierarchy. This philosophy has a cost in expressiveness. One easy example is the lack of function argument defaults7:

Experience tells us that defaulted arguments make it too easy to patch over API design flaws by adding more arguments, resulting in too many arguments with interactions that are difficult to disentangle or even understand.

And method overloading8:

Experience with other languages told us that having a variety of methods with the same name but different signatures was occasionally useful but that it could also be confusing and fragile in practice.

Dependencies

Expressiveness is also affected by how much you can expect to get done with the built-ins and the standard library —as opposed to writing utilities by yourself or relying on external dependencies. I found Go to be uneven on this front: on one hand, the standard library has many “batteries-included” types of packages (HTTP servers, templates, embedded files); on the other, common data structure operations and utilities are missing, so you have to implement them yourself (or, let’s be honest, copy them from a previous project, or from StackOverflow, or ask ChatGPT).

There seems to be an inclination in the Go community to avoid external dependencies when possible. I like this convention, it makes software more reliable and maintainable, and sets Go apart from other languages9. But paired with a non-comprehensive standard library, this means writing more custom code for basic tasks.

Going back to the file copy example, my mental model for the operation was the shell command cp src dest, which translates directly into Python’s shutil.copy and Rust’s std::fs::copy. Since Go doesn’t provide such a function, I needed to unfold that mental model into its set of lower-level operations:

open src file (handle error)
create target file (handle error)
copy source data into target (handle error)
flush target (handle error)
close source
close target

I encountered a similar situation with unit test helpers. Go has no built-in assertions for the same reasons that it doesn’t support argument defaults: they can be misused. There are no assertions in the testing utilities, either, because allegedly plain Go should be enough. Unsurprisingly, I found that pure Go unit tests are plagued by unreadable error-checking boilerplate. I saw that other programmers either use an external testing library or write custom assertion functions for this purpose. The second seemed more in line with the no-dependencies convention, so I copied an example from StackOverflow which, after some iterations and debugging, ended up like this:

func assert(t *testing.T, cond bool) {
	t.Helper()
	if !cond {
		t.Fatalf("%v is false", cond)
	}
}

func assertEqual(t *testing.T, a interface{}, b interface{}) {
	t.Helper()
	if a != b {
		t.Fatalf("%v != %v", a, b)
	}
}

Mindset

I went into Go programming expecting to find a boring language —in the good sense of the word. And I found it, to the extent that Go is unpretentious, designed to avoid unnecessary sophistication and its associated complexity. But Go is not boring in the sense of always doing what you would expect: it’s not without quirks and rough edges10. At best it’s simple; at worst, feature-poor. At best, pragmatic; at worst, inconsistent. At best, beginner-friendly; at worst, patronizing. At best, informed by real-world applications; at worst, tailor-made for Google.

The gofmt tool is a good metaphor for the language as a whole. You could make the case for why spaces are marginally better than tabs for indentation, but even if they were, the benefit of removing the question of tabs vs. spaces altogether far outweighs the marginal cost of making the wrong choice. The underlying principle: a language that makes as many decisions for programmers as possible, will make them more productive, regardless of whether those decisions are optimal.

I may not like some of its design choices, or how they are justified, but I see the merit in building a language to meet very specific goals, opting out of fashionable features, and then sticking to that design over the years, resisting the temptation to “improve” on it. That may well be Go’s killer feature. And, while I disagree with the notion that programmers need to be protected from themselves, I do believe in the creative power of restrictions, in doing more with less, in approaching the work with a beginner’s mind.

Notes


1

In previous posts, I already mentioned that Go was easy to learn and that I was positively impressed by its concurrency facilities.

2

In Python, for instance, I need to write import feedi.parsers.rss as rss to get a similar effect. And, since it’s not the default, external code doesn’t always play well with this usage pattern.

3

Python’s take on errors ironically seems to go against its philosophy: “explicit is better than implicit” and (to a lesser degree since runtime crashes aren’t precisely quiet) “errors should never pass silently”.

4

One obvious one is the “comma ok” idiom to check if a map contains an element. More closely related to error handling, there are special syntax rules to redeclarate return values, and I presume the defer construct was introduced specifically to keep resource management sane in the context of frequent early returns.

5

See this post for a detailed discussion of the day-to-day annoyances of Go’s approach to errors, from the perspective of the developer writing the code. The follow-up post explores the idea of extending Go with Rust’s question mark operator.

6

Following Rich Hickey’s distinction between simple and easy, expressiveness is more like the latter: something that’s in the eye of the beholder, that you wouldn’t use to justify a technical decision.

9

See Our Software Dependency Problem by Russ Cox, a core Go developer.

10

See the 100 Go Mistakes book (“simple to learn but hard to master”), and the 50 Shades of Go.