8.3 Creating Slices: Literals, make(), and Slicing Arrays
Right, let’s get our hands dirty with the three main ways you conjure a slice into existence. This isn’t just about syntax; it’s about understanding what you’re actually asking the runtime to do for you under the hood. Each method has its own personality and its own performance implications.
Slice Literals: The Quick and Easy
This is the most straightforward way. You just declare what you want, and Go does the work.
myGreetings := []string{"Hello", "Bonjour", "Hola"}
It feels innocent, but here’s the first bit of runtime magic you need to know: the compiler creates an anonymous array first, holding those three values, and then it sets up your myGreetings slice to point to it. The slice’s length and capacity are both set to 3. It’s a neat, ready-to-go package.
You can do this for empty slices too, which is incredibly common.
// An empty slice, ready to be appended to.
var emptyWorkers []string
workers := []string{} // More idiomatic, means the same thing.
// A nil slice. Often the preferred default state.
var nilWorkers []string
Hold on, did I just say two different things? You bet I did. emptyWorkers and workers are non-nil slices with a length of 0. nilWorkers is a nil slice, also with a length and capacity of 0. Functionally, in most operations (len(), append), they behave identically. The difference is under the hood: the non-nil empty slice has a pointer to a zero-length array somewhere in memory (which is… weird, but fine), while the nil slice has a nil pointer. This can matter for things like marshaling to JSON (nil becomes null, []string{} becomes []) or if you ever need to check if a slice was ever initialized.
The make() Function: Planning Ahead
make() is for when you’re thinking about performance. You use it when you know you’re going to need a slice of a certain size, and you want to avoid the immediate reallocations that a naive append loop would cause. You’re telling the runtime, “I know what I’m doing, allocate this much space now.”
// Slice of 5 strings, all set to their zero value ("").
// Both length and capacity are 5.
tasks := make([]string, 5)
// Slice of 5 strings (length), but with room for 10 (capacity).
// This is the power move.
highScores := make([]int, 5, 10)
Here’s the critical distinction that bites everyone: make([]string, 5) doesn’t create a slice with room for 5 elements; it creates a slice that already has 5 elements (all empty strings). If you append to it, you’re adding a 6th element. If you meant to create an empty slice but with a pre-allocated capacity, you use the three-argument form: make([]string, 0, 10). Now you have a slice with length 0 and capacity 10. The underlying array is allocated, and your next 10 append operations will be lightning fast because they won’t need to find new memory and copy everything over.
Slicing an Existing Array (or Another Slice)
This is where the whole “slice is a view” concept becomes crystal clear. When you create a slice from an array or another slice, you are not creating a new independent collection. You’re creating a new slice header that points to a subsection of the exact same underlying array.
months := [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
q2 := months[3:6] // [Apr May Jun]
q2 now has a length of 3, but what’s its capacity? It’s not 3. It’s 9. Why? Because the capacity is from the start of the slice (Apr, index 3 in the underlying array) all the way to the end of the array (Dec, index 11). This is a classic source of bugs.
fmt.Println(cap(q2)) // 9
// Let's try to extend our Q2 slice into Q3... seems logical, right?
q2Extended := q2[:6] // We're asking for indices 0 to 6 within q2's view.
fmt.Println(q2Extended) // [Apr May Jun Jul Aug Sep]
It worked! But only because we had the capacity. We just extended our view into the months array. Now, watch what happens if we modify through one view:
q2Extended[0] = "APRIL FOOLS"
fmt.Println(months[3]) // "APRIL FOOLS"
fmt.Println(q2[0]) // "APRIL FOOLS"
This mutates the original array and every other slice that shares that memory. It’s incredibly efficient (no copying), but it’s also a fantastic way to create spooky action at a distance if you’re not careful. The best practice here is to assume slicing creates a view, and if you need to break that connection, you use copy() or append() to a new slice to force a new allocation.
// Break the link! This creates a new underlying array.
independentQ2 := append([]string(nil), q2...)
independentQ2[0] = "Actually April"
fmt.Println(months[3]) // Still "APRIL FOOLS"
fmt.Println(independentQ2[0]) // "Actually April"