Pitfalls of Slices, Channels, and nil in Go: Deep Dive into Implementation Details
Go slices appear simple, but predictable behavior requires understanding their internal mechanics. Since slices reference a backing array, unexpected mutations and memory leaks can occur.
Let’s examine the basic case of array reuse:
a := []int{1, 2, 3, 4}
b := a[1:3] // b = [2, 3]
b[0] = 99
fmt.Println(a)
Output: [1 99 3 4]. Modifying b affects the original array a.
Now add append to a:
a := []int{1, 2, 3, 4}
b := a[1:3]
a = append(a, 5)
b[0] = 99
fmt.Println(a)
Output: [1 2 3 4 5]. The append operation allocated a new array, so changes to b no longer affect the original.
Subtleties of append and Capacity
func main() {
a := []int{1, 2, 3, 4}
_ = append(a[:3], 5)
fmt.Println(a)
}
Output: [1 2 3 5]. There was enough capacity, so no reallocation occurred.
With explicit capacity limiting:
func main() {
a := []int{1, 2, 3, 4}
_ = append(a[:3:3], 5)
fmt.Println(a)
}
Output: [1 2 3 4]. Limiting capacity triggered reallocation.
Extending a slice beyond its length:
func main() {
s := []int{1, 2, 3, 4, 5}[1:3]
fmt.Println(s)
extendedSlice := s[:4]
fmt.Println(extendedSlice)
}
Output:
[2 3]
[2 3 4 5]
Memory Leaks and Slice Passing
A small slice from a large array holds the entire buffer:
bigArray := make([]int, 1e6)
smallSlice := bigArray[:10]
Passing by value mutates the underlying array but doesn’t change capacity:
func modifySlice(s []int) {
s[0] = 99
s = append(s, 100)
}
func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s)
}
Output: [99 2 3].
Loop with references to a mutable element:
func main() {
s := []int{}
refs := []*int{}
for i := 0; i < 5; i++ {
s = append(s, i)
refs = append(refs, &s[0])
}
*refs[4] = 4
*refs[0] = 99999
fmt.Println(s)
}
Output: [4 1 2 3 4].
Inconsistency of nil for Slices and Maps
var s []int
fmt.Println(len(s)) // 0
s = append(s, 1)
var m map[string]string
fmt.Println(len(m)) // 0
m["key"] = "value" // panic
A nil slice works fine, but a nil map panics on assignment.
Strings as Bytes
Strings store bytes:
func main() {
str := "å"
fmt.Println(str[1]) // 165
}
func main() {
str := "Three"
fmt.Println(len(str)) // 6
}
Shadowing Predeclared Identifiers
func main() {
true := false
uint := "bob"
string := 0
fmt.Printf("%v, %v, %v", true, uint, string)
}
Output: false, bob, 0.
Channels: Reading and Closing
Reading requires checking the channel state:
ch := getCountChannel[int]()
if v, ok := <-ch; ok {
fmt.Println(v)
} else {
fmt.Println("channel closed")
}
Setting a channel to nil disables the select branch:
var in <-chan int = ch
if paused {
in = nil
}
select {
case v := <-in:
fmt.Println("got", v)
case <-ctx.Done():
return
}
After close(), zero values are read from the channel:
func main() {
ch := make(chan int, 1)
ch <- 0
close(ch)
fmt.Println(<-ch) // 0 true
fmt.Println(<-ch) // 0 false
fmt.Println(<-ch) // 0 false
}
With check:
v, ok := <-ch
fmt.Println(v, ok)
Typed nil in Interfaces
type MyErr struct{}
func (MyErr) Error() string { return "boom" }
func f() error {
var e *MyErr = nil
return e
}
func main() {
err := f()
fmt.Println(err == nil) // false
}
An interface holds both type and value. Solution:
func f() error {
var e *MyErr = nil
if e == nil {
return nil
}
return e
}
Issues with for range and Pointers
vals := []int{1, 2, 3}
ptrs := []*int{}
for _, v := range vals {
ptrs = append(ptrs, &v)
}
fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 3 3 3
The variable v is reused across iterations.
Key Takeaways:
- Slices share a backing array: changes are visible everywhere.
appendmay reallocate memory when capacity is exceeded.nilslices allowappend;nilmaps do not.- Strings store bytes;
len()counts bytes. - Typed
nilin interfaces is not equal tonil. for rangecreates a single loop variable.
— Editorial Team
No comments yet.