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.
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:
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:
// 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
}
// 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 */ }
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
//
}
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.
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.
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.
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.
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.
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.
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
}
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.
Hope you enjoyed the post!