I’ve been using Go for a year now and am deeply immersed in its simplicity of design, as described on its website.

Go is expressive, concise, clean, and efficient. It’s a fast, statically typed, compiled language that feels like a dynamically typed, interpreted language.

Rob Pike in Simplicity is Complicated also mentions Go’s simplicity as an important reason for its popularity. Simplicity does not mean simplicity, and Go has a number of design features that ensure that complexity is hidden behind the scenes. In this article, I discuss the design philosophy and best practices of struct/interface in Go to help readers write robust and efficient Go programs.

Structures Struct

Go is designed to replace C/C++, so struct in Go is similar to C, and is a value type like int/float, which is memory compact, fixed size, and GC and memory access friendly.

1
2
3
type Point struct { X, Y int }
type Rect1 struct { Min, Max Point }
type Rect2 struct { Min, Max *Point }

go struct

As you can see from the figure above, Point Rect1 Rect2 are all contiguous in memory. The value types need to be used with the following two points in mind.

  1. When an assignment is made, a copy of the value is made, unlike the reference-based Object in Java.

    The difference between Java objects and Go struct assignment

  2. Because the assignment of a value type makes a copy, it needs to be defined as a pointer type when its value needs to be changed.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
        type student struct {
            name string
        }
    
        foo := student{name: "foo"}
        bar := foo
        bar.name = "bar"
        fmt.Println(foo.name)  // 输出 foo
    
        bar2 := &foo
        bar2.name = "bar"
        fmt.Println(foo.name)  // 输出 bar
    

The above example is relatively simple, but it is easy to overlook when nesting structs within other structures, such as when using for range to traverse []struct, map[xx]struct. there are some pitfalls when using for range.

Also, in some cases, Go directly restricts the modification of structs at the language level. Here is an example.

1
2
3
4
    m := map[int]student{
        1: {name: "1"},
    }
    m[1].name = "2" // 编译错误: cannot assign to struct field m[1].name in map

As you can see, it is not possible to assign a value to the struct in map directly. This is because m[1] gets a copy of the original struct, and even if the compiler allows the assignment here, the value of the struct in the map will not change, so the compiler directly disallows it. Second, the assignment here is a read-modify-write operation, which makes it difficult to guarantee atomicity, as discussed in #3117. There are two ways to resolve this.

I have encountered this “trap” many times, so does it mean that all structs are defined as pointers? Here we need to understand Go’s escape analysis to answer this question.

Fugitive analysis

The main role of escape analysis is to determine where objects are allocated in memory, Go tries to allocate them on the stack, which has obvious benefits: easy recycling and less GC pressure. This can be seen with go build -gcflags -m xx.go.

1
2
3
4
5
6
7
8
9
func returnByValue(name string) student {
    return student{name}
}

func returnByPointer(name string) *student {
    return &student{name}
}

./snippet.go:6:18: &student literal escapes to heap

As you can see, the return value of the returnByPointer method escapes and ends up on the heap. For more information on the performance difference between variables assigned on stack / heap, see: bench_test.go

Test results.

1
2
3
4
5
6
7
8
9
go test -run ^NOTHING -bench Struct *.go
goos: darwin
goarch: amd64
BenchmarkPointerVSStruct/return_pointer-8               33634951                34.3 ns/op            16 B/op          1 allocs/op
BenchmarkPointerVSStruct/return__value-8                530202802                2.23 ns/op            0 B/op          0 allocs/op
BenchmarkPointerVSStruct/value_receiver-8               433067940                2.77 ns/op            0 B/op          0 allocs/op
BenchmarkPointerVSStruct/pointer_receiver-8             431380804                2.72 ns/op            0 B/op          0 allocs/op
PASS
ok      command-line-arguments  5.889s

As you can see.

  • When the method returns a pointer, there is a heap allocation
  • When the method returns value, there is no heap allocation, which means that all variables are allocated on the stack
  • There is little difference in performance between a receiver being a pointer or a value, because s has no escape in either case, and copying the struct itself costs about the same as copying a pointer (8 bytes)

This test also shows that the location of the variable allocation in memory is independent of whether it is a pointer or not. Combining the results of the above test, the following process can be followed to determine whether to use pointers.

  1. If you need to change the state (e.g. to include waitgroup/sync.Poll/sync.
  2. As a function return value, unsafe.Sizeof(struct) is greater than a certain threshold, the time to copy is greater than the time to allocate on the heap, and the pointer is chosen
  3. As a function parameter, for range objects (all will copy the value), if the object is large, use the pointer
  4. In addition, struct can be

To determine the threshold in 2, you can add an array to the struct (arrays are also value types) and run the above test. In my machine, the threshold is about 72K.

1
2
3
4
5
6
7
type student struct {
    name string
    dummy  [9000]int64  // 添加一数组元素
}

BenchmarkPointerVSStruct/return_pointer-8                 150147              8147 ns/op           73728 B/op          1 allocs/op
BenchmarkPointerVSStruct/return__value-8                  138591              8146 ns/op               0 B/op          0 allocs/op

Few structs are of this magnitude, due to the fact that slice/map/string, which are commonly used in Go, are composite types, which are characterized by a fixed size, e.g. string takes up only 16 bytes (for 64-bit systems), similar to the structure below.

1
2
3
4
type StringHeader struct {
    Data uintptr
    Len  int
}

Memory allocation for Go strings

The following table summarizes the classification of data types in Go.

value type composite type
bool slice
numeric map
(unsafe)pointer channel
struct function
array interface
string
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    fmt.Println(map[string]uint64{
        "ptr":       uint64(unsafe.Sizeof(&struct{}{})),
        "map":       uint64(unsafe.Sizeof(map[bool]bool{})),
        "slice":     uint64(unsafe.Sizeof([]struct{}{})),
        "chan":      uint64(unsafe.Sizeof(make(chan struct{}))),
        "func":      uint64(unsafe.Sizeof(func() {})),
        "interface": uint64(unsafe.Sizeof(interface{}(0))),
    })

    // 输出
    map[chan:8 func:8 interface:16 map:8 ptr:8 slice:24]

You can see that

  • chan/func/map/ptr are all 8 bytes, i.e. one pointer to concrete data
  • interface is 16, two pointers, one to a specific type and one to specific data. See Russ Cox’s Go Data Structures: Interfaces for details
  • slice is 24, including a pointer to the underlying array, two integers, and the distributions cap, len

It was mentioned above that you can’t directly modify the struct in a map, so is the following procedure legal? Why?

1
2
3
    m := map[int][]int{1: {1, 2, 3}}
    m[1][0] = 11
    fmt.Println(m)

Memory alignment

Fields in struct are aligned by machine word length, so where performance is critical, you can try to put fields of the same type together.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    fmt.Println(
        unsafe.Sizeof(struct {
            a bool
            b string
            c bool
        }{}),
        unsafe.Sizeof(struct {
            a bool
            c bool
            b string
        }{}),
    )

The above code will output 32 24 in sequence, and the following illustration clearly shows the layout of the two sequential structs in memory: (image source)

field_align

Finally, the reader can consider the results of the following code run.

1
2
3
4
    fmt.Println(
        unsafe.Sizeof(interface{}(0)),
        unsafe.Sizeof(struct{}{}),
    )

Interface

If struct is the encapsulation of state, then interface is the encapsulation of behavior, and is the basis for constructing abstractions in Go. Since there is no concept of oop in Go, the integration of different components, such as Reader/Writer under the io package, is achieved through combination rather than inheritance. But there is no advantage to combination, which is also possible in Java, but the implicit “inheritance” in Go makes combination very flexible.

Embedded struct

This is illustrated by an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type RecordWriter struct {
    code int
    http.ResponseWriter
}

func (rw *RecordWriter) WriteHeader(statusCode int) {
    rw.code = statusCode
    rw.ResponseWriter.WriteHeader(statusCode)
}

func URLStat(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
    // if w.WriteHeader isn't called inside handlerFunc, 200 is the default code.
    rw := &RecordWriter{ResponseWriter: w, code: 200}
    next(rw, r)
    metrics.HTTPReqs.WithLabelValues(r.URL.Path, r.Method, strconv.FormatInt(int64(rw.code), 10)).Inc()
}

The above code snippet is a middleware in negroni to record the http code. The custom Writer implements the ResponseWriter interface by embedding the ResponseWriter and then The entire implementation is very simple and concise, as it uses the pointer type *RecordWriter as a receiver since it needs to change state.

New func type

The second example is about how to simplify err handling by customizing the type. In net/http, handlerFunc has no return value, which leads to a null return after each exception to abort the logic, which is not only tedious but also easy to miss.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func viewRecord(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

The problem can then be solved by customizing the new type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type appError struct {
    Error   error
    Message string
    Code    int
}
type appHandler func(http.ResponseWriter, *http.Request) appError

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if e := fn(w, r); e != nil { // e is *appError, not os.Error.
        c := appengine.NewContext(r)
        c.Errorf("%v", e.Error)
        http.Error(w, e.Message, e.Code)
    }
}

func viewRecord(w http.ResponseWriter, r *http.Request) appError {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return appError{err, "Record not found", 404}
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        return appError{err, "Can't display record", 500}
    }
    return appError{}
}

mux.HandleFunc("/view", appHandler(viewRecord))

As you can see, the above example achieves the need to centralize err handling by defining a new function type for appHandler and implicitly “inheriting” from the http.Handler The nice thing about this implementation is that it adds new types to the functions and the function signatures are consistent with ServeHTTP, so that the parameters can be reused directly. For beginners, it may not occur to you to define methods for func types as well, but in Go, you can add methods to any type.

I’ve seen some frameworks on the web that use panic to simplify err handling, but I think this is a misuse of panic, not to mention the loss of performance, but mainly because it breaks the if err ! = nil processing. I hope readers will consider how to abstract new types to solve the problem when dealing with tedious logic in the future.

Summary

The subtle design of Go ensures that its features are simple and may be different from traditional oop, so it’s not wrong for readers who have switched from these languages to think in old ways. But as a good Go programmer, you need to think more in terms of Go’s own features, so that you don’t have to wonder “why are there no XX features in Go? You should know that the authors of Go are Rob Pike, Ken Thompson :-) If you have read/implemented the interface-based design, please feel free to share.