Arrow of time
Arrow of time

Go: methods, receivers and benchmarking

Share Tweet Share

One interesting feature of Go is how easy it is to write tests and microbenchmarks for it. In fact, tests …

One interesting feature of Go is how easy it is to write tests and microbenchmarks for it. In fact, tests and microbenchmarks roughly folow the same syntax.

Another interesting "feature" of Go is how it introduced subtleties in language which can be tarpits for beginners and those familiar with other languages. Take for example the Go equivalent of classes. There are no classical classes here, but a way of doing things which is similar to Rust and even JavaScript. First you declare a data structure:

type rect struct {
    width, height int
}

then you attach methods to it:

func (r *rect) area() int {
    return r.width * r.height
}

Note the *rect in the declaration above: it means we are attaching a function named area() which operates on a pointer to the rect structure. In C++, Java, Python and others, this happens by default, the implicit this (or explicit self in case of Python) is always a pointer. In Go, on the other hand, it's perfectly fine to write:

func (r rect) perim() int {
    return 2*r.width + 2*r.height
}

Here, we are attaching a function named perim() to the by-value data of type rect. (You can think of r in this and the above example something like an explicit self of Python. In Go, it is called a receiver). Now consider this: if r is passed-by-value, what happens behind the scenes?

Yes, you guessed it, the whole struct is copied on each and every invocation of perim().

For complex structures, this could surely wreak havoc. Luckily, Go doesn't have real constructors, oveloaded operatores and other magic which might mean code is invoked on such copies. At the very least, this may introduce subtle performance problems. Take for example simple methods operating on a customized string:

type MyStr string

func (s MyStr) Test() {
        if s[0] == '-' {
                fmt.Println("eh?")
        }
}

func (s *MyStr) Test2() {
        if (*s)[0] == 'z' {
                fmt.Println("oh?")
        }
}

When calling the first method, the string will always be copied. When calling the second, a pointer is used. Note the somewhat ugly syntax (*s)[0] - I find Go doesn't shy away from inelegant syntax.

Anyway, data copies are additional operations the CPU has to perform, and as such can slow everything down. Just how much depends on the length of the string.

Assuming the above code (with MyStr) is saved in main.go, we can write another source file, main_test.go which will contain its tests and benchmarks:

package main

import (
        "testing"
)

func BenchmarkMyStr1(b *testing.B) {
        var a MyStr = "blah"
        for n := 0; n < b.N; n++ {
                a.Test()
        }
}

func BenchmarkMyStr2(b *testing.B) {
        var a MyStr = "klah"
        for n := 0; n < b.N; n++ {
                a.Test2()
        }
}

The tests can be run with:

go test -bench .

The results for the above case, with 4-byte strings are:

BenchmarkMyStr1 300000000                5.12 ns/op
BenchmarkMyStr2 300000000                5.02 ns/op

The conclusion: in this case, The difference of 0.1 ns is not that big. Modern CPUs are fast and I found that the strings need to contain at least 256 bytes before additional measurable difference can be observed. So, the copies themselves are not a huge deal for performance, unless the structures are big. The more probable reason for avoiding them is simple memory efficiency - less copying means less garbage scattered around in memory.


comments powered by Disqus