Simple Nuance

go philosophy

Full Disclosure: I’m not a real developer, I don’t even play one on YouTube. I am most certainly complecting simple and easy. I had intended to keep the new blog focussed on projects, but apparently philosophical reflection is unavoidable… Apologies in advance.

A person smarter than me said, “Nothing is truly simple. There is always nuance.” After tripping over this a couple times, it felt like an example worth sharing.

You’re a humble DevOps practitioner. You like Go because it’s beautifully C-like without requiring real smarts. You’ve read Effective Go a few times, understand block scope and rely heavily on redeclaration.

One day you write this snippet (obfuscated to protect the guilty) which lints, compiles and passes all tests (clearly I could use better tests, I told you IANAD):

// in a caller far, far away...
ctx := context.Background()
res, err := someFunc(ctx)

// later in someFunc...
var things []foo.Thing
if len(thingID) > 0 {
	thing, err := getThing(ctx, thingID)
	if err != nil {
		logger.Fatal(err)
	}
	things = append(things, thing)
} else {
	things, err := getThings(ctx)
	if err != nil {
		logger.Fatal(err)
	}
	if len(things) == 0 {
		fmt.Println("no things!")
		return
	}
}

The code started only supporting collections. When adding support for single items, things was declared in advance and control flow added to assign as needed (real developers would keep the happy path aligned, but I’m avoiding that holy war since it’s orthogonal to this discussion).

The single case works fine since things is assigned with =. While you understood := to magically redeclare or reassign as needed, the else fails miserably because things is in fact not assigned as expected (it will create a new block-scoped things, with subsequent code operating on the originally declared things). You confirm this the only way a non-programmer knows how — lots of fmt.Printf statements checking the length and content of things. Yep, it’s an empty list.

In desperation your primordial DevOps brain rewrites… It looks sloppy, but works. You manage to sleep at night by convincing yourself this really is somewhat idiomatic (one nice thing about Go is not getting overly obsessed with DRY or trying to be clever at the cost of readability; “just writing more code” is often the idiomatic way):

var things []foo.Thing
if len(thingID) > 0 {
	thing, err := getThing(ctx, thingID)
	if err != nil {
		logger.Fatal(err)
	}
	things = append(things, thing)
} else {
	t, err := getThings(ctx)
	if err != nil {
		logger.Fatal(err)
	}
	if len(t) == 0 {
		fmt.Println("no things!")
		return
	}
	things = t
}

What gives? Re-reading the docs you see:

In a := declaration a variable v may appear even if it has already been declared, provided:

  • this declaration is in the same scope as the existing declaration of v (if v is already declared in an outer scope, the declaration will create a new variable),
  • the corresponding value in the initialization is assignable to v, and
  • there is at least one other variable that is created by the declaration.

I’ve read that many times and rely on it almost daily, but occasionally still find myself holding an incorrect mental model. My brain decides the first version feels right, even though the declaration is scoped to the nearest block. Just as it should be.

You sit back, take another sip of coffee, and think about the epiphany… Nope, no epiphany here. Just another “DUH WTF” moment. You know your developer friends will give you side-eye over the extra variable or not dropping the else entirely, but just as you start another rewrite the pager goes off. #devopslife