Elegant code and Go

Writing elegant or readable code is a driving force for some programmers that have been around long enough to know that less code is usually better than more. We also know that less code is usually also less efficient than more, for various reasons. Depending on how you like your cat skinned, there are several ways to do things.

You can work with something as simple as a password generator. Let’s go with a hands on example of writing a function that might generate a password for us. A quick google for it leads me to a few different examples:

Runes

This password generator creates a rune slice of a specified length, and chooses each character (rune) before converting it to string and returning it. This uses the math/rand package.

var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

func RandStringRunes(n int) string {
    b := make([]rune, n)
    for i := range b {
        b[i] = letterRunes[rand.Intn(len(letterRunes))]
    }
    return string(b)
}

With a SLOC of 6 and 4 function calls, it’s clear what it does. I’m just not sure that anybody really cares to type out the alphabet or even paste it from some example.

Bytes

The same example but with a byte slice.

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func RandStringBytes(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.Intn(len(letterBytes))]
    }
    return string(b)
}

I literally see no improvement ofer the above. Still at the same SLOC 6/ function calls metric, we’re not improving anything over the rune example. We still read it as the same function.

Modulo / remainder of rand

This function attempts to optimize the way the random characters are chosen.

func RandStringBytesRmndr(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.Int63() % int64(len(letterBytes))]
    }
    return string(b)
}

Notably, this function resorts to rand.Int63 and a modulo operator that would return the remainder of the division with the character set length. Supposedly, this is faster, but the readability hasn’t improved even a little, staying at SLOC 6, and introducing an additional function call (casting int to int64).

Inspecting crypto/rand

The standard library provides the crypto/rand package. The package provides a Reader io.reader which makes it possible to read a specific number of random bytes. For example, we can read a fixed amount of them like this:

raw := make([]byte, length)
io.ReadFull(rand.Reader, raw)

This gets us half of the way there to generating a fixed length password. Unfortunately each byte is in the range from 0 to 255, out of which about half of the characters are unreachable without an extreme combination of keyboard presses. That doesn’t make good password material.

But wouldn’t it be great, if we didn’t need letterBytes or something? In fact we don’t. The package encoding/base64 already provides RawURLEncoding which would be good enough for our purposes. Our generator would look like this:

func GenerateRandomString(length int) string {
	raw := make([]byte, length)
	io.ReadFull(rand.Reader, raw)
	return base64.RawURLEncoding.EncodeToString(raw)[:length]
}

And the underlying set of characters used by base64.RawURLEncoding is:

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_

Obviously, there’s some overhead with base64 encoding and the reslice, but our SLOC just dropped to 4. Our function calls just dropped from 4 to 3. The function is wonderfully easy to read, and there’s no noise from letterBytes. We know and rely on the implementation details of the base64 package to make this code short and concise.

Conclusions

People should sometimes step back from their benchmarks, and also optimize code for readability. While I wouldn’t advocate one choice over another in the given examples, I do realize that there are clear differences and implications between them. Reading concise and non-tivial code improves the overall feeling about the elegance of the code base.

To be honest, a high amount of new, make and casting calls between types, especially bytes and string types, are usually a code smell indicating further problems with memory allocation. Of course, this is coming from somebody who just advocated about 25% of allocation overhead in the last example above (and this can be improved as well if you recalculate the length of the byte slice).

Take my advice with a pinch of salt, and improve readability where you can. Less is more.

While I have you here...

It would be great if you buy one of my books:

I promise you'll learn a lot more if you buy one. Buying a copy supports me writing more about similar topics. Say thank you and buy my books.

Feel free to send me an email if you want to book my time for consultancy/freelance services. I'm great at APIs, Go, Docker, VueJS and scaling services, among many other things.