Skip to content

Copy Behaviors in Go

In cpp, there are concepts of reference , value, right value and left value. The go is very similar with some idolisms of C++, for example we cannot copy the iostream in cpp, as the same io in go always passed by reference with the help of interface.

However, go doesn’t have reference acurrately, as it only has concepts about value and pointer. The reference in title should be called pointer also.

C++ has a dilicated way to control what happened when copying and moving, but golang doesn’t have such concepts, which means the complexities are hidden in the compiler and runtime.

Let’s focus on the copy behavior of basic types in golang, we can see that when copying, map, chan and context are treated as reference, the origin one and copied one share the same one. It’s worthy to know why some types are copied by reference instead of value.

Copy interface

context is an interface type, so we’d better to know how go implements interface.

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

In [runtime.runtime2.go](https://github.com/golang/go/blob/release-branch.go1.19/src/runtime/runtime2.go#L202), the iface is the interface implementation, the [itab](https://github.com/golang/go/blob/release-branch.go1.19/src/runtime/runtime2.go#L909) contains the description of a type. It’s enough to explain why the interface is copied like reference. As it only contains two pointers for underlying date, copying pointer still points the same object.

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

type itab struct {
    inter *interfacetype
    _type *_type
  // copy of _type.hash. Used for type switches.
    hash  uint32
    _     [4]byte
  // variable sized. fun[0]==0 means _type does not implement inter.
    fun   [1]uintptr
}

Map

Interface is easy to understand once we know the interface behavior. However, how the map copys as a reference, it’s not an interface type.

Checking with the dave’s blog, we can know some key points:

  • map type is actually a pointer to the runtime unexported object.
  • compiler and runtime work together to implement map struct.

Compiling time rewrite

rewrite operation by runtime implementation.

We cannot see the definition of map, unlike int, double , etc… As the real implementation is hidden inside the hmap in runtime/map.go.

Here I only show some functions about the hmap:

func makemap64(t *maptype, hint int64, h *hmap) *hmap
func makemap(t *maptype, hint int, h *hmap) *hmap
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer)

They are the functions to operate the hmap , but how they are connected to the map object created by ourselves? It’s the work of compiler, it will convert the function to runtime implementation:

v := m["key"]      runtime.mapaccess1(m, key", &v)
v, ok := m["key"] → runtime.mapaccess2(m, ”key”, &v, &ok)
m["key"] = 9001   → runtime.mapinsert(m, ”key", 9001)
delete(m, "key")   runtime.mapdelete(m, key)

We can confirm this from the go assembly, the runtime function is called after compiling.

// main.go:5 is  m[2] = 1, m is map[int]int
0x0076 00118 (main.go:5)        LEAQ    type.map[int]int(SB), AX
0x007d 00125 (main.go:5)        LEAQ    main..autotmp_1+168(SP), BX
0x0085 00133 (main.go:5)        MOVL    $2, CX
0x008a 00138 (main.go:5)        PCDATA  $1, $0
0x008a 00138 (main.go:5)        CALL    runtime.mapassign_fast64(SB)

Assign type by compiler

Checking the function declaration, all of them have an argument with type maptype. Accroding to the name, the map already knows the key value pair type when declaring, why it still a maptype and where the value comes from?

Basically, interface is not used when implementing map. For generic, maptype is introduced for each unique map declaration, which stores the type information of key-value.

Rather than having, as C++ has, a complete map implementation for each unique map declaration, the Go compiler creates a maptype during compilation and uses that value when calling into the runtime’s map functions.

[By the way]: why map must use make, cannot new?

An intesting fact is that the map “object” we declared is a pointer essetially. That’s why if we now make a map, it will cause a panic as the object is nil.

func main() {
    var p uintptr
    var m map[int]int
        // output is: 8 8
    fmt.Println(unsafe.Sizeof(m), unsafe.Sizeof(p))
        // output is: true
        fmt.Println(m==nil)
}

Let’s check the differences between make and not make. The following assembly shows the code if we declare map by make(map[int]int. However, the line4 disapper if we only declare but not allocate.

0x0026 00038 (main.go:4)        MOVUPS  X15, main..autotmp_1+168(SP)
0x002f 00047 (main.go:4)        MOVUPS  X15, main..autotmp_1+184(SP)
0x0038 00056 (main.go:4)        MOVUPS  X15, main..autotmp_1+200(SP)
0x0041 00065 (main.go:4)        LEAQ    main..autotmp_2+24(SP), DI
0x0046 00070 (main.go:4)        PCDATA  $0, $-2
0x0046 00070 (main.go:4)        LEAQ    -48(DI), DI
0x004a 00074 (main.go:4)        DUFFZERO        $313
0x005d 00093 (main.go:4)        PCDATA  $0, $-1
0x005d 00093 (main.go:4)        LEAQ    main..autotmp_2+24(SP), AX
0x0062 00098 (main.go:4)        MOVQ    AX, main..autotmp_1+184(SP)
0x006a 00106 (main.go:4)        PCDATA  $1, $1
0x006a 00106 (main.go:4)        CALL    runtime.fastrand(SB)
0x006f 00111 (main.go:4)        MOVL    AX, main..autotmp_1+180(SP)

0x0076 00118 (main.go:5)        LEAQ    type.map[int]int(SB), AX
0x007d 00125 (main.go:5)        LEAQ    main..autotmp_1+168(SP), BX
0x0085 00133 (main.go:5)        MOVL    $2, CX
0x008a 00138 (main.go:5)        PCDATA  $1, $0
0x008a 00138 (main.go:5)        CALL    runtime.mapassign_fast64(SB)

The make calls the function in runtime and allocate the space to store data. If we only declare but not allocate, we will get a nil pointer which still contains the type(nil could has a type).

The built-in function make(T, args) serves a purpose different from new(T). It creates slices, maps, and channels only, and it returns an initialized (not zeroed) value of type T (not *T). The reason for the distinction is that these three types represent, under the covers, references to data structures that must be initialized before use. A slice, for example, is a three-item descriptor containing a pointer to the data (inside an array), the length, and the capacity, and until those items are initialized, the slice is nil. For slices, maps, and channels, make initializes the internal data structure and prepares the value for use.

If we use new, it retruns *T pointer which only allocates the memory, if we use new to create map, only panic will stay there for us.

func main() {
    m := new(map[int]int)
        // panic as nil
    (*m)[1] = 1
}