Personal website of Martin Tournoij (“arp242”); writing about programming (CV) and various other things.

Working on GoatCounter and moreGitHub Sponsors.

Contact at martin@arp242.net or GitHub.

This page's author

Discussions: Lobsters, /r/golang, Hacker News.

A “Feature of Last Resort” (FOLR) is a useful feature which solves certain otherwise hard-to-solve problems, but are often best avoided.

I stole this term from Mercurial’s Features of Last Resort wiki page. As far as I know it originated on that page (and isn’t widely used outside of it). Many complex systems have these kind of FOLRs, and being explicit about it makes things easier for new users, who do not yet have the experience to distinguish them from regular features.

At any rate, this is a list of what I consider FOLRs in Go.[1]

Struct tags

Struct tags allow annotating struct fields with extra metadata to be retrieved at runtime with reflection:

type T struct {
    F int `json:"f"`
}

Why it exists and when to use – provide name aliases for marshalling and unmarshalling, which would be tricky otherwise (pass a map[string]string to json.Marshal()?)

Problems – lack of type checking; typos can pass silently. vet catches a bunch of spacing errors, but won’t catch misspellings. Libraries that use struct tags often don’t error out at runtime (you can never be quite sure if a struct tag is intended for you or not, so erroring out may cause conflicts).

Especially some of the more creative uses of struct tags is essentially “programming by comments”. There is no “discoverability” in the form of API docs, code completion, etc. If I see the struct tag valid:"yikes" then how do I figure out what this means to who? With regular Go code I can “jump to definition” or grep, but for struct tags this is much harder.

It also rather hard to read (non-aligned) and edit (follows from being hard to read).

Alternatives – it depends on what exactly you’re doing, but data (e.g. map) or function calls can cover almost all struct tag cases.

Empty interface

The empty interface (interface{}) is an interface with no methods, and is satisfied by any type.

Why it exists and when to use – sometimes you really need to accept any type (but this is not very common for most types of programs).

Problems – no compile-time type checks; limited tooling support. It often leads to using reflection, which is hard, may be quite slow, and often buggy or incomplete. If you do use interface{}, then do your best to stick to type assertions if at all possible.

Alternatives – this is the Great Go Generics Debate. Alternatives include:

  • Write it multiple times for each type (possibly generated).
  • Use an actual interface with methods.
  • Convert to other type; e.g. just accept int64 instead of int and call as fun(int64(i)).

See Summary of Go Generics Discussions for some more details and examples.

Imports aliases, dot-imports

Imports can be aliased as b "foo/bar", so that instead of bar.X you use b.X. All identifiers can be imported to the current namespace with . "foo/bar", in which case you’d use just X.

Why it exists and when to use – sometimes you need to alias packages to prevent conflicts (e.g. math/rand and crypto/rand). It’s also needed when a directory name contains invalid identifier characters (e.g. go-pkg).

The dot-imports exist mostly for tests that run outside the package they’re testing (pkg_test packages).

Problems – names often assume the package name, for example zip.File is clearer than z.File or File. It almost always hurts readability. Especially when used to shorten longer package names it can be unclear what something like b refers to.

Alternatives – just use regular imports.

panic()

The panic() builtin displays a message, stack trace, and halts the program, unless recover()‘d.

Why it exists and when to use – sometimes the program really cannot continue and the only thing left to do is to “panic”. It’s a way to indicate that something impossible has happened (e.g. exiting an infinite loop), initialisation errors (“can’t find foo.html”), or some types of programmer errors (“can’t pass empty string”).

I think “never use panic”, or even “never use panic in libraries” is too strict. Using panics to signal initialisation errors is often convenient, especially for functions which would otherwise not have error returns.

Problems – using panics as if they’re exceptions is probably the most common mistake I’ve seen new Go programmers make. It’s understandable (I did it myself!), but it’s not how Go is expected to be used.

Panics can sort-of be used like exceptions if you squint enough, but there is no easy way to recover() just a few lines of code (just functions), or recover()-ing only specific errors (leading to “Pokemon exceptions”, “gotta catch ‘em all”). In other words, they’re not really exceptions. Go doesn’t implement exceptions since they’re generally considered to not be worth the cost (see also).

In general it’s best to forget that recover() exists and consider panics as a shortcut for:

fmt.Fprintln(os.Stderr, "oh noes!")
debug.PrintStack()
os.Exit(1)

Are you sure this is what you want to do? Sometimes it is, but often it’s not.

Alternatives – most of the time just return errors.

init()

All init() functions in a package are automatically run when it’s imported.

Why it exists and when to use – initialize data and set up state, for example lookup tables or some package-global object.

Problems – there are a bunch of (potential) problems:

  • It’s “hidden” code that gets run, which can be surprising and unexpected especially if the code is non-trivial.
  • Relying on global state is often not the best solution.
  • It can make testing needlessly difficult.
  • It’s hard to signal errors (outside of panic).
  • Can be a waste of resources if init() sets up something for A(), and you just want B()

Alternatives – depending on what you want to do:

  • Instead of relying on package globals, using NewFoo() Foo constructors and passing Foo as a function argument is often a good alternative. See A theory of modern Go.

  • Initialize the variable on first use with sync.Once:

    var (
        geodbOnce sync.Once
        geodb     *geo.DB
    )
    
    func getGeo(ip string) string {
        // Will be run once only; if this is called again while it's still
        // running the second call will be blocked until the function completes
        // (but won't run it).
        geodbOnce.Do(func() {
            g, err := geoip2.FromBytes(pack.GeoDB)
            if err != nil {
                panic(err)
            }
            geodb = g
        })
    
        return geodb.Lookup(ip).CountryCode
    }
    

    The advantage of this is that the code will only be run if the function is actually called, rather than on import. This is used quite a bit in the standard library.

  • For setting up a package-level variable a self-executing function will work just as well, and is clearer about what it’s doing instead of relying on side-effects:

    var geodb = func() *geoip2.Reader {
        g, err := geoip2.FromBytes(pack.GeoDB)
        if err != nil {
            panic(err)
        }
        return g
    }()
    

    Which is even shorter than the init() solution:

    var geodb *geoip2.Reader
    
    func init() {
        var err error
        geodb, err = geoip2.FromBytes(pack.GeoDB)
        if err != nil {
            panic(err)
        }
    }
    

    Note I don’t agree these kind of package-level variables should always be avoided like A theory of modern Go advocates. e.g. in the example above geodb is essentially just a fancy IP address → country code lookup table, and passing that through several layers of functions for just one call is IMO less clear.

cgo

cgo allows calling C code from Go.

Why it exists and when to use – interacting with C libraries, which are ubiquitous. Perhaps the most commonly used cgo library is SQLite, which is a perfectly valid use case and would be hard to implement without cgo (although there are some efforts to translate the SQLite C code to Go, but this incurs quite a performance impact).

Problems – it’s comparatively slow, it inherits some of C’s problems such as unsafe memory, can be tricky to understand by Go programmers, and it makes builds slower and more tricky. See cgo is not Go for a more detailed breakdown of possible issues.

Alternatives – write it in Go if at all feasible.

Footnotes
  1. The list is not 100% comprehensive. For example Go compiler directives and arguably type aliases could also be included, but I have not witnessed them being abused often so I excluded them for brevity.