A Deep Dive into Go Slices: Understanding Value vs. Reference, Modifying and Adding Elements
Constantine Yachnytskyi
Distributed Systems Engineer | Golang Software Developer | Rust Software Developer | Golang Software Engineer | Rust Software Engineer
Go slices are one of the most powerful yet misunderstood data types in the language. They provide a dynamic way to work with arrays, which are otherwise fixed in size. In this article, we’ll explore the main concepts around Go slices, their behavior, and how you can manipulate them efficiently in your Go programs.
Introduction to Slices
Before we dive into the code, let’s clarify what a slice is. In Go, slices are built on top of arrays but are more flexible. A slice has three main components:
While arrays in Go are fixed in size, slices can dynamically grow and shrink, providing much more flexibility.
Basic Slice Operations
Let’s begin by understanding how slices behave in various situations with some simple examples.
Example 1: Slices vs Arrays
package main
import "fmt"
func modifyArray(arr [3]int) {
arr[0] = 99
fmt.Println("Inside function:", arr)
}
func modifySlice(s []int) {
fmt.Println("Inside function:", s)
s[0] = 100
}
func main() {
myArray := [3]int{1, 2, 3}
modifyArray(myArray)
fmt.Println("Outside function:", myArray)
mySlice := []int{1, 2, 3}
modifySlice(mySlice)
fmt.Println("Outside function:", mySlice)
}
What’s Happening Here?
Example 2: Adding Elements to a Slice
func addElement(s []int) {
s = append(s, 4)
s = append(s, 5)
fmt.Println("Inside function:", s)
}
func main() {
mySlice := make([]int, 3, 4) // Slice with length 3 and capacity 4
addElement(mySlice)
fmt.Println("Outside function:", mySlice) // Notice the slice is unchanged
}
What’s Going on with append()? The append() function in Go adds elements to the end of a slice. However, here’s the key point:
This is why, even if we have enough capacity and add elements with append(), the changes won’t be visible outside the function unless we explicitly return the modified slice or pass a pointer to the slice.
Example 3: Modifying a Slice and Returning It
func modifyAndReturnSlice(s []int) []int {
fmt.Println("Inside function:", s)
s[0] = 100
s = append(s, 4)
return s
}
func main() {
mySlice := make([]int, 3, 4)
mySlice = modifyAndReturnSlice(mySlice)
fmt.Println("Outside function:", mySlice) // Modifications persist
}
In this case, modifications persist because we’re returning the modified slice. Since slices are value types, a new slice is returned, and the changes are reflected outside the function. To modify a slice and have those changes persist, we need to either return a new slice or pass a pointer to the slice.
Example 4: Modifying a Slice Using Pointers
func addElementByPointer(s *[]int) {
fmt.Println("Inside function:", *s)
*s = append(*s, 4)
}
func main() {
newSlice := []int{1, 2, 3}
addElementByPointer(&newSlice)
fmt.Println("Outside function:", newSlice) // Modifications persist
}
Here, we pass a pointer to the slice to the function. This allows us to modify the slice directly. This is often used in Go when you want to modify a slice and have those changes reflected outside the function, as the slice is passed by reference via its pointer.
Adding Elements at the Beginning or Middle of a Slice
Appending elements to the end of a slice is efficient, but what happens if you need to insert elements at the beginning or middle of a slice? These operations are less efficient because it requires shifting the existing elements.
领英推荐
Let’s look at an example for adding elements at the beginning and middle:
Example 5: Inserting Elements at the Beginning and Middle
package main
import "fmt"
// Insert element at the beginning
func insertAtBeginning(s []int, value int) []int {
s = append([]int{value}, s...) // Add the new element at the start
return s
}
// Insert element at the middle
func insertAtMiddle(s []int, value int) []int {
mid := len(s) / 2
s = append(s[:mid], append([]int{value}, s[mid:]...)...) // Insert in the middle
return s
}
func main() {
mySlice := []int{1, 2, 3, 4, 5}
// Insert at the beginning
mySlice = insertAtBeginning(mySlice, 0)
fmt.Println("After inserting at the beginning:", mySlice)
// Insert at the middle
mySlice = insertAtMiddle(mySlice, 99)
fmt.Println("After inserting at the middle:", mySlice)
}
What’s Happening Here?
Performance Considerations:
Memory Considerations and Resizing
One important thing to note is that resizing a slice, especially when elements are added to it frequently, can impact performance. When a slice exceeds its capacity, Go will allocate a new array, and this operation can be costly if done repeatedly.
To improve performance, it’s a good practice to preallocate memory as much as possible, especially if you know the maximum size of the slice in advance.
Example: Preallocating Memory for Slices
func preallocateAndAppend(s []int) []int {
// Preallocate memory for a slice to avoid reallocation
s = make([]int, 0, 100) // Start with capacity for 100 elements
for i := 0; i < 100; i++ {
s = append(s, i)
}
return s
}
func main() {
mySlice := preallocateAndAppend([]int{})
fmt.Println("Final slice:", mySlice)
}func main() {
mySlice := preallocateAndAppend([]int{})
fmt.Println("Final slice:", mySlice)
}
In this example, we preallocate a slice with a capacity of 100, ensuring that appending elements doesn’t cause multiple reallocations. This is crucial when working with large datasets or in performance-sensitive applications.
Performance Considerations with Slices
In Go, slices are backed by arrays, so performance is an important consideration. Let’s examine two important aspects:
Benchmarking
To see the performance difference when modifying and adding elements to slices, let’s benchmark a couple of functions. Below are the results from running benchmarks on slice operations with different slice sizes:
goos: darwin
goarch: arm64
pkg: golang-examples/lesson/slice
cpu: Apple M1 Pro
BenchmarkModifyAndReturnSlice/Size_1-10 37618783 31.17 ns/op 24 B/op 2 allocs/op
BenchmarkModifyAndReturnSlice/Size_10-10 20038503 57.94 ns/op 240 B/op 2 allocs/op
BenchmarkModifyAndReturnSlice/Size_100-10 4408605 283.6 ns/op 2688 B/op 2 allocs/op
BenchmarkModifyAndReturnSlice/Size_1000-10 635025 1917 ns/op 20480 B/op 2 allocs/op
BenchmarkModifyAndReturnSlice/Size_10000-10 76478 14304 ns/op 188416 B/op 2 allocs/op
BenchmarkModifyAndReturnSlice/Size_100000-10 10000 110335 ns/op 1810445 B/op 2 allocs/op
BenchmarkModifyAndReturnSlice/Size_1000000-10 1269 921012 ns/op 18006025 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_1-10 37690315 31.33 ns/op 24 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_10-10 20095873 58.39 ns/op 240 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_100-10 4420986 273.2 ns/op 2688 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_1000-10 619660 1835 ns/op 20480 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_10000-10 83298 13871 ns/op 188416 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_100000-10 10000 110683 ns/op 1810446 B/op 2 allocs/op
BenchmarkAddElementByPointer/Size_1000000-10 1240 965831 ns/op 18006026 B/op 2 allocs/op
PASS
ok golang-examples/lesson/slice 17.639s
Conclusion
In Go, slices are a powerful and flexible tool for managing data, but understanding their underlying behavior is crucial for writing efficient code. Some important takeaways include:
By understanding how Go slices work under the hood, you can take full advantage of their dynamic capabilities while maintaining efficient performance in your applications.
For more in-depth analysis and the full source code, visit GitHub — Golang Examples. Also, check out my YouTube video Go Slices Insights for additional insights and tips.