Despite the fact that it’s been around longer than I’ve been a programmer, I feel like the buzz around the Go programming language has increased of late. This might be why, a few weeks ago, the company I’m working with announced their intention to start migrating portions of their Node microservices into Go. Since then I’ve spent some time learning Go, and pitching in on a Test Double open source project called Diplomat in order to get my bearings.
The first stage of picking up a new language usually involves learning syntax: the low-level hows and whats that make up your average piece of code. How do you declare and assign variables? How do you iterate over collections of data, and what do those structures look like? How does concurrency work, if at all? I spent my first day or so working through the Tour of Go, and mapping the new syntax onto my pre-existing understanding of what code can do.
=, spent a lot of unnecessary time trying to handle errors like they were exceptions, and managed to place one-way channel type arrows incorrectly every single time in a particularly rough pairing session.
At the same time, the puzzlement I experienced while learning these syntactical tricks paled in comparison to my first encounter with
GOPATH. I pulled an existing Go repository down from GitHub, tried to run it, and watched it fizzle anti-climactically. Which is when I learned that projects running Go 1.10 and lower have to be placed inside a particular, preset path on your machine, known as the
GOPATH. It meant putting code repositories outside the meticulous system I’d built in my home directory, which kept personal projects in a different space than client work. I felt an inward recoil at this restriction, and I wondered why developers were willing to accept it (as it turns out, they weren’t entirely willing).
The confusion about how Go works eventually led to the second emotional stage: getting frustrated with it. Since I’d been successful in other languages, it was easier to blame Go than my coding skills when things went wrong.
This is when I started working on Diplomat. I wasn’t around at the inception of the codebase - another Test Double agent was the originator - and he’d provided a Makefile with a set of scripts to make jumping in easier. The main script,
watch, ran the formatting, build, testing, and linting steps on every file change. This is a lot of steps, which made for lots of opportunities for my terminal to shout at me.
And shout it did. If I left an unused variable in a function, it wouldn’t run any tests until it was fixed. If the compiler failed in one part of the codebase, it wouldn’t run unit tests for the other parts. It wouldn’t run integration tests unless all the unit tests passed. I spent a non-trivial amount of that first day fighting with and against that
watch script. I still wasn’t familiar with the compiler errors, and I had a bad habit of making several big changes between saves, both of which combined to make identifying errors difficult.
In retrospect, some of this pain was the natural outcome of figuring out a new language. But some of it was self-inflicted, a result of my trying to force my usual programming habits onto a language where they didn’t quite fit. At one point during a pairing session, I made a change that led to open hanging channels. When the
make script output went blank, I restarted it. After doing this a couple of times, my pair suggested that
make probably wasn’t the issue. He was right; the issue was that I didn’t know what was wrong, and I was hoping that it was somehow the tooling’s fault.
The transition out of the frustration phase occurred when I finally decided to stop fighting the Go tooling and work with it. At first, my inclination was to make big, sweeping changes and pick up the pieces later. Unfortunately the
watch script made this really difficult; because it contained all the build steps, messing up one thing essentially meant shutting down my entire feedback cycle, and I wasn’t proficient enough in Go to put the pieces back together without help from the compiler. So I changed my approach.
I started making smaller changes, saving and checking the
watch output between each change. If something broke, I either went back or used the compiler output to tell me what to do next. That made it simpler to thread a single type change through the entire application, for example, instead of trying to make five type changes at once and blowing everything up. I was able to refactor a big component by building up a second one and using TDD to migrate behavior into it.
In the end, finding a solution to my frustrations led to increased curiosity, both about Go and about how I approach programming. I was able to ask better questions about how Go works, and how our packages should be architected to be understandable and easier to work with. I was engaging in a dialogue with the language and the tools, instead of trying to impose my will on them. At one point during a refactor we observed a repeated pattern throughout our packages, which we couldn’t abstract away because Go doesn’t support a generic type. This natural guardrail led me to reflect on why I was averse to duplicated code, and consider the complexity cost of compulsive DRY-ing. I realized that simply knocking my mind off of its usual tracks created space for curiosity about things that didn’t specifically involve Go.
The last few weeks have also given me pause to reflect on the perceived fluidity of what we call “coding skills” in our industry. It can be tempting to operate as though programming languages are interchangeable, and assume that software developers can flow between languages at any time. Which, unfortunately, isn’t the case (as much as we’d like it to be). Developers, and humans in general, struggle to context switch between different activities. In a similar way, I think it’s important to view a change of language as a change of context, and ensure that a team has the time and space to make their own journey to understanding.