Some lessons I’ve learned over the years

I’ve been at this for a while. I first started coding as a kid, when I was around 11, and professionally a little over a decade ago. The language I’ve been doing the most in the past 5 years has been Go.

What follows are some lessons I’ve learned over the years, some general, some Go-specific.

1. Optimize for readability, not performance

This is perhaps the single most important point. Code is often read much more than it is written and in most cases, writing new code involves reading the code surrounding it.

Keeping this in mind, try to approach every bit of code you write assuming you’ll need to come back to it in a year’s time and either refactor it or extend it to add new features.

A lot of the time in programming we’re reading code, building a mental map of that code, and then figuring out how to modify that mental map.

Many of the points in this post are related to simplifying the cognitive load of parsing code, but here are some things you can do:

Proper naming

A famous adage in computer science goes:

There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.

In that vein, always try to your best to name variables and functions properly.

Some pointers:

  • tie name length to scope, single letter variables are fine for a few lines

// this is fine 
for _, u := range users {
   // do something with u 
}

// this is not fine, opt for user or usr instead
func process(u User) error {
   // 40 lines of code 
}
  • don’t add the type to the name, particularly if you’re in a typed language

// this is fine 
name := "James"

// this is not fine 
nameStr := "James"

// a properly named object 
type NameInfo struct {
    firstName string
    lastName string 
    lastUpdated time.Time 
}

// can be used as 
nameInfo := NameInfo{ /* populate fields */ }

Keep indentation to a minimum

Try and reduce the amount of ‘V’s in code. I’ll expand on this later in the article, but the core idea is: minimizing indentation = improving readability.

Here’s an example:


if condition {
    if secondCondition {
        if thirdCondition {
            // 
            // do something 
            // 
        } else {
            return 
        }
    }
}

Could be made simpler as:


shouldCheck := condition && secondCondition 

if shouldCheck {
    if !thirdCondition {
        return 
    }
    
    // 
    // do something 
    // 
}

Break up big blocks into separate function calls

Something I have seen sometimes is large swathes of code, with only a few comments here and there, that you need to go through a few times until you understand exactly what’s going on.

Break that large function into separate subfunctions. I lean on linters to force me to break up code, at the moment I may be annoyed at it but it always pays off later on. The only exceptions are TestXXX methods.

2. Sketch first, then code

I often write code by first writing one function where I sketch out the code a potential user of the library would write. This often forces me to write out the API calls I’d potentially need to write. I then start writing tests for each of these API functions, until eventually I reach a working MVP.

Here’s an example. Suppose I want to write a binary that lists all windows services (and golang.org/x/sys/windows/svc/mgr doesn’t exist).

I’ll go mod init github.com/aalbacetef/svcmgr, then mkdir -p ./cmd/svcmgr/ and start writing ./cmd/svcmgr/main.go:


import (
    "encoding/json"
    "os"

    "github.com/aalbacetef/svcmgr"
)

type MinimalServiceView struct {
    Name string `json:"name"`
    State svcmgr.State `json:"state"`
}

func main() {
    mgr, err := svcmgr.New()
    if err != nil { /* log and exit */ }

    services, err := mgr.ListServices()
    if err != nil { /* log and exit */ }

    data := make([]MinimalServiceView, 0, len(services))

    // only get the Name and State prop
    for _, svc := range services {
        s := MinimalServiceView{Name: svc.Name, State: svc.State}
        data = append(data, s)
    }

    fd, err := os.OpenFile("data.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
    if err { /* log and exit */ }
    defer fd.Close()

    encoded, err := json.MarshalIndent(data, "", "  ")
    if err != nil { /* log and exit */ }

    n, err := fd.Write(encoded)
    if err != nil || n != len(encoded) { /* log and exit */ }
}

I’ll then write a TestListServices test and work on the code until it passes and consequently, my MVP binary works.

This is conceptually related to TDD (test-driven development), where you write tests as means of sketching out your code. I just write some code from the user’s point-of-view first to force myself to outline the minimal API I need.

3. No init (prefer explicit Init calls), unless you really have to

This point is quite Go-specific but some of the most difficult to debug situations have been those involving init functions.

Some context, Go allows one to declare (lowercase) init functions which are called when a package is loaded.

In every scenario I’ve encountered, a specific Init() method made the code easier to reason about. Not to mention, they almost always have global variables associated with them.

You don’t want to opt-in for imports that have side effects unless you really need to.

It is usually better to wrap your usage of that code in a function, and call Init() at the top of it, using the returned variables instead of init + globals.

Note: I am aware there is a peculiarity of Mac that might make init desirable in some cases, see: github.com/golang/go/issues/23112 for more info.

4. No globals

Tied in to the above point, try and minimize the use of globals as much as you can. They make tests and debugging particularly cumbersome.

5. Keep your error strings identifiable

Go’s error handling paradigm is distinct from exception-driven languages where you often lean into a stacktrace. While you can print it, this is not very common and usually done only in the case of panics.

The idiomatic approach is to make it easy to grep for the error message in your code base and find it. Also, make use of logging. Usually loggers allow you to add context to log lines, such as the function/struct/package the log call is made from.

6. Bugs are great, they’re a good sign of where to increase test coverage

Unless the bug is very trivial or due to a typo, whenever a bug pops up I always take that as a chance to first write tests that catch the bug (i.e: they fail) and then fix the bug.

This helps prevent future code changes from introducing undetected regressions.

7. You probably didn’t need to use else

This is probably more of a personal style thing, but I find that almost dogmatically refusing to write else clauses leads to better code.

It often keeps code easier to read and reduces indentation.

For example:


func handleEvent(ev Event) error {
    if paramIsDefined(ev, "param") {
        param := getParam(ev, "param")

        if validate(param) {
            doSomething(ev)
            return nil 
        } else {
            return InvalidParamError{param}
        }
    } else {
        return ParamNotFoundError{param}
    }
}

could be made simpler:

func handleEvent(ev Event) error {
    if !paramIsDefined(ev, "param") {
        return ParamNotFoundError{"param"}
    }

    param := getParam(ev, "param")

    if !validate(param) {
        return InvalidParamError{param}
    }

    doSomething(param)

    return nil
}

Usually flipping conditions or returning early cover most cases.

Another one is default values:


var value int 

if someCond {
    value = 1 
} else {
    value = 2 
}

could be simplified with:


value := 2
if someCond {
    value = 1 
}

8. Don’t fear the Switch

Switch statements are one of the most underused constructs, with developers reaching for large nested if-else-if trees instead.

While Go’s switch statement is not as powerful as the match operators that can be found in other languages (such as Ocaml’s match), in many situations it is still applicable.

For example:


if v.StrValue == "A" {
    handleA()
} else if v.StrValue == "B" {
    handleB() 
} else if v.StrValue == "C" {
    handleC()
} else if v.StrValue == "D" || v.StrValue == "E" {
    handleDE() 
} else {
    handleNotFound()
}

could be replaced with:


switch(v.StrValue){
    case "A":
        handleA()
    case "B":
        handleB()
    case "C":
        handleC()
    case "D", "E":
        handleDE()
    default:
        handleNotFound()
}

Cleaner, but also easier to expand.

Conclusion

Hope you enjoyed the post!