Always be cautious about the slice in Golang since it’s not so reference-like as a “reference type”.

Slice is a Reference Type

According to The Go Programming Language by Alan A. A. Donovan and Brian W. Kernighan, different from array which is an aggregate type, slice is a reference type because when you pass a Slice to a function, you may change its underlying array indirectly and it can be seen by other Slice variables sharing the same underlying array.

However, unlike the reference type Array in Java, if you change the length of a Slice by passing it to a function, the change of len and cap will not be seen out side of the function. It’s easy to understand if you look into the source code of it.

Source Code of Slice Type

Let’s look into the source code of Slice type in runtime/slice.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package runtime

import (
	"internal/abi"
	"internal/goarch"
	"runtime/internal/math"
	"runtime/internal/sys"
	"unsafe"
)

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

// ... ...

We can see the built-in type slice is defined as a struct with three fields: a pointer to the underlying array, and two integers len and cap.

One Case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 2, 4}
	del(s, 2)
	fmt.Println(s)
}

func del(nums []int, target int) {
	for i := len(nums) - 1; i >= 0; i-- {
		if nums[i] == target {
			nums = append(nums[:i], nums[i+1:]...)
		}
	}
}

This program trys to delete targets from an integer slice.

The output is “[1 3 4 4 4]” however, since the underlying array was changed twice from {1, 2, 3, 2, 4} to {1, 2, 3, 4, 4} and then {1, 3, 4, 4, 4} while len(s) and cap(s) were never changed in main()

In order to make the function del() work as expected (outputing “[1, 3, 4]” in this case), one solution is to pass the pointer of the slice.

Solution 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 2, 4}
	del(&s, 2)  // pass the pointer of the slice
	fmt.Println(s)
}

func del(nums *[]int, target int) {
	for i := len(*nums) - 1; i >= 0; i-- {
		if (*nums)[i] == target {
			*nums = append((*nums)[:i], (*nums)[i+1:]...)
		}
	}
}

Another solution is to return the updated slice and store it to the original slice variable.

Solution 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 2, 4}
	s = del(s, 2)  // update the slice variable
	fmt.Println(s)
}

func del(nums []int, target int) []int {
	for i := len(nums) - 1; i >= 0; i-- {
		if nums[i] == target {
			nums = append(nums[:i], nums[i+1:]...)
		}
	}
	return nums
}

Be careful when you append()

In practice, the second solution above is more common and also more graceful.

The built-in function append() also takes the second way:

The append built-in function appends elements to the end of a slice. If it has sufficient capacity, the destination is resliced to accommodate the new elements. If it does not, a new underlying array will be allocated.

Append returns the updated slice. It is therefore necessary to store the result of append, often in the variable holding the slice itself:
slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)

As a special case, it is legal to append a string to a byte slice, like this:
slice = append([]byte("hello "), "world"...)

1
func append(slice []Type, elems ...Type) []Type

As the documentation says, if the slice has no enough capacity, a new underlying array will be allocated, otherwise the appended values will just be assigned to next vacancy of the original arrays with length +1 simply.

Therefore, never assume whether the underlying array is the same one or a different one after using append() or other function that may change the length or capacity of a slice or make it refer to a different underlying array.

We can’t assume that the original slice refers to the same array asthe resulting slice, nor that it refers to a different one. Similarly, we must not assume that operations on elements of the old slice will (or will not) be reflected in the new slice.

As a result, it’s usual to assign the result of a call to append to the same slice variable whose value we passed to append.

–– The Go Programming Language (Alan A. A. Donovan and Brian W. Kernighan)

Do not be assumptive about the underlying array of a slice.

Example (LeetCode No.39)

In the LeetCode Problem No.39 Combination Sum, we are asked to find all possible combinations of numbers that add up to a target number.

Here is a common solution using backtracking to find the combinations recursively, which contains a bug though:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func combinationSum(candidates []int, target int) [][]int {
    var ans [][]int
    sort.Ints(candidates)
    var dfs func(sum int, combination []int, candidates []int)
    dfs = func(sum int, combination []int, candidates []int) {
        if sum == target {
            ans = append(ans, combination)
            return
        }
        if sum > target {
            return
        }
        for i := range candidates {
            combination := append(combination, candidates[i])
            dfs(sum + candidates[i], combination, candidates[i:])
        }
    }
    dfs(0, nil, candidates)
    return ans
}

This solution failed to pass all the test cases on LeetCode, because the combination slice is changed in the recursive function dfs() and the change is sometimes reflected in the ans slice.

Solution 1

In the 14th line, make sure that it allocates a new slice with a new underlying array to pass to the recursive function dfs().

This is a simple way to assure a new underlying array with the same content.

1
newCombination := append([]int{}, combination...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func combinationSum(candidates []int, target int) [][]int {
    var ans [][]int
    sort.Ints(candidates)
    var dfs func(sum int, combination []int, candidates []int)
    dfs = func(sum int, combination []int, candidates []int) {
        if sum == target {
            ans = append(ans, combination)
            return
        }
        if sum > target {
            return
        }
        for i := range candidates {
            newCombination := append([]int{}, combination...)
            newCombination = append(newCombination, candidates[i])
            dfs(sum + candidates[i], newCombination, candidates[i:])
        }
    }
    dfs(0, nil, candidates)
    return ans
}

Solution 2

In the 7th line, we can also use this method to make sure that the combination appended to the ans slice is a new one.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func combinationSum(candidates []int, target int) [][]int {
    var ans [][]int
    sort.Ints(candidates)
    var dfs func(sum int, combination []int, candidates []int)
    dfs = func(sum int, combination []int, candidates []int) {
        if sum == target {
            ans = append(ans, append([]int{}, combination...))
            return
        }
        if sum > target {
            return
        }
        for i := range candidates {
            combination := append(combination, candidates[i])
            dfs(sum + candidates[i], combination, candidates[i:])
        }
    }
    dfs(0, nil, candidates)
    return ans
}

Appendix: How Slice Grows

The slice growing strategy is in thegrowslice() function in runtime/slice.go:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// growslice allocates new backing store for a slice.
//
// arguments:
//
//	oldPtr = pointer to the slice's backing array
//	newLen = new length (= oldLen + num)
//	oldCap = original slice's capacity.
//	   num = number of elements being added
//	    et = element type
//
// return values:
//
//	newPtr = pointer to the new backing store
//	newLen = same value as the argument
//	newCap = capacity of the new backing store
//
// Requires that uint(newLen) > uint(oldCap).
// Assumes the original slice length is newLen - num
//
// A new backing store is allocated with space for at least newLen elements.
// Existing entries [0, oldLen) are copied over to the new backing store.
// Added entries [oldLen, newLen) are not initialized by growslice
// (although for pointer-containing element types, they are zeroed). They
// must be initialized by the caller.
// Trailing entries [newLen, newCap) are zeroed.
//
// growslice's odd calling convention makes the generated code that calls
// this function simpler. In particular, it accepts and returns the
// new length so that the old length is not live (does not need to be
// spilled/restored) and the new length is returned (also does not need
// to be spilled/restored).
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
	oldLen := newLen - num
	if raceenabled {
		callerpc := getcallerpc()
		racereadrangepc(oldPtr, uintptr(oldLen*int(et.size)), callerpc, abi.FuncPCABIInternal(growslice))
	}
	if msanenabled {
		msanread(oldPtr, uintptr(oldLen*int(et.size)))
	}
	if asanenabled {
		asanread(oldPtr, uintptr(oldLen*int(et.size)))
	}

	if newLen < 0 {
		panic(errorString("growslice: len out of range"))
	}

	if et.size == 0 {
		// append should not create a slice with nil pointer but non-zero len.
		// We assume that append doesn't need to preserve oldPtr in this case.
		return slice{unsafe.Pointer(&zerobase), newLen, newLen}
	}

	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		newcap = newLen
	} else {
		const threshold = 256
		if oldCap < threshold {
			newcap = doublecap
		} else {
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < newLen {
				// Transition from growing 2x for small slices
				// to growing 1.25x for large slices. This formula
				// gives a smooth-ish transition between the two.
				newcap += (newcap + 3*threshold) / 4
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {
				newcap = newLen
			}
		}
	}

	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	// Specialize for common values of et.size.
	// For 1 we don't need any division/multiplication.
	// For goarch.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
	// For powers of 2, use a variable shift.
	switch {
	case et.size == 1:
		lenmem = uintptr(oldLen)
		newlenmem = uintptr(newLen)
		capmem = roundupsize(uintptr(newcap))
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.size == goarch.PtrSize:
		lenmem = uintptr(oldLen) * goarch.PtrSize
		newlenmem = uintptr(newLen) * goarch.PtrSize
		capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
		overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
		newcap = int(capmem / goarch.PtrSize)
	case isPowerOfTwo(et.size):
		var shift uintptr
		if goarch.PtrSize == 8 {
			// Mask shift for better code generation.
			shift = uintptr(sys.TrailingZeros64(uint64(et.size))) & 63
		} else {
			shift = uintptr(sys.TrailingZeros32(uint32(et.size))) & 31
		}
		lenmem = uintptr(oldLen) << shift
		newlenmem = uintptr(newLen) << shift
		capmem = roundupsize(uintptr(newcap) << shift)
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		newcap = int(capmem >> shift)
		capmem = uintptr(newcap) << shift
	default:
		lenmem = uintptr(oldLen) * et.size
		newlenmem = uintptr(newLen) * et.size
		capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
		capmem = roundupsize(capmem)
		newcap = int(capmem / et.size)
		capmem = uintptr(newcap) * et.size
	}

	// The check of overflow in addition to capmem > maxAlloc is needed
	// to prevent an overflow which can be used to trigger a segfault
	// on 32bit architectures with this example program:
	//
	// type T [1<<27 + 1]int64
	//
	// var d T
	// var s []T
	//
	// func main() {
	//   s = append(s, d, d, d, d)
	//   print(len(s), "\n")
	// }
	if overflow || capmem > maxAlloc {
		panic(errorString("growslice: len out of range"))
	}

	var p unsafe.Pointer
	if et.ptrdata == 0 {
		p = mallocgc(capmem, nil, false)
		// The append() that calls growslice is going to overwrite from oldLen to newLen.
		// Only clear the part that will not be overwritten.
		// The reflect_growslice() that calls growslice will manually clear
		// the region not cleared here.
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
			// Only shade the pointers in oldPtr since we know the destination slice p
			// only contains nil pointers because it has been cleared during alloc.
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.size+et.ptrdata)
		}
	}
	memmove(p, oldPtr, lenmem)
	return slice{p, newLen, newcap}
}

Creative Commons License Licensed under CC BY-SA 4.0

Comments