Lesson 10 / 18
Slice Operations
The same append call, with the same source text, gives two different results at two different capacities: sharing continues within the capacity limit and writing to the derived slice changes the source, while sharing is cut off once capacity is exceeded and the source stays untouched. Two notations close this gap: an independent copy taken with copy, and the three-index slice expression that explicitly bounds capacity.
Published Updated
In this lesson
- Reads the default values of slicing's unwritten bounds
- Measures whether the same append call continues or cuts off sharing depending on capacity
- Uses copy and the three-index slice expression as two notations that close sharing's unpredictability
Contents
The previous lesson closed with a question: the same append call, with the same source
text, gives two different results at two different capacities. The Arrays and Slices lesson
showed sharing only in direct assignment — in a line like shareSlice := sourceSlice. This
lesson looks at the more commonly encountered form of sharing: another slice derived from a
slice by slicing.
Slicing is one of the most frequently seen operations in Go source: walking a file’s lines, separating a network packet’s header from its body, taking a particular section of a text all use the same syntax. This prevalence also raises the cost of not knowing how sharing works — because every slicing line establishes an invisible bond.
The Default for an Unwritten Slice Bound
A slicing expression has the form source[start:end], and one or both of the two bounds may
go unwritten. This was exactly one of the fifteen unwritten cases the Zero Values lesson
measured: leaving slice bounds unwritten is defined — it corresponds to 0 when the start
is unwritten and to the slice’s length when the end is unwritten.
// bounds.go — the unwritten ends of a slicing bound
package main
import "fmt"
func main() {
source := []int{10, 20, 30, 40}
fmt.Println("source[:] :", source[:])
fmt.Println("source[1:]:", source[1:])
fmt.Println("source[:2]:", source[:2])
}
source[:] : [10 20 30 40] source[1:]: [20 30 40] source[:2]: [10 20]
All three are derived from the same source and all three point to the same backing array;
none of them takes a copy. source[:] leaves both bounds to their default and covers the
whole slice, source[1:] writes only the start, source[:2] writes only the end. The numbers
these three lines produce are the same as the Zero Values lesson’s binding table; there, 8 of
the 15 unwritten cases fell into the defined class, and slice bounds were one of them.
An unwritten bound and a wrongly written bound are not the same thing. When the end was left blank, it fell back to the slice’s length; when the end is written explicitly and exceeds capacity, the result is not a defined value, it is a run-time stop.
// overrun.go — an unwritten bound is defined, a bound exceeding capacity is open
package main
import "fmt"
func panics(f func()) (p bool) {
defer func() {
if recover() != nil {
p = true
}
}()
f()
return false
}
func main() {
source := []int{10, 20, 30, 40}
validPanicked := panics(func() { _ = source[1:3] })
fmt.Println("source[1:3] (valid range) panic=", validPanicked)
overrunPanicked := panics(func() { _ = source[1:9] })
fmt.Println("source[1:9] (end exceeds capacity) panic=", overrunPanicked)
}
source[1:3] (valid range) panic= false source[1:9] (end exceeds capacity) panic= true
A bound left blank and a bound written too large fall into two separate classes here: the first is a defined value the language fills in itself, the second is an error the compiler cannot catch and that is left to run time. The difference is not whether it is written, it is whether the written value stays within capacity.
Writing to a Derived Slice Changes the Source
Slicing does not produce a new backing array; the derived slice points to the same backing array the source points to, with a different length and starting point. This is why writing to the derived slice also changes the source.
// derived.go — writing to a slice derived by slicing changes the source
package main
import "fmt"
func main() {
source := []int{10, 20, 30, 40}
derived := source[1:3]
derived[0] = 99
fmt.Println("derived:", derived)
fmt.Println("source :", source)
}
derived: [99 30] source : [10 99 30 40]
derived := source[1:3] produces a two-element slice covering the second and third elements
(20, 30). When derived[0] = 99 is written, what changes is not derived’s own data, it
is the second element of the backing array shared with source; this is why source[1] also
becomes 99. This is the same sharing seen in direct assignment in the Arrays and Slices
lesson — only how the slice was obtained changed, the rule itself did not.
This sharing also continues among more than two slices, because all of them stay bound to the same backing array. When two slices are derived from the source so that they overlap, a write to the overlapping position is visible from both.
// overlapping.go — two slices overlapping from a source, a write to the shared position shows in both
package main
import "fmt"
func main() {
source := []int{10, 20, 30, 40, 50}
left := source[0:3]
right := source[2:5]
left[2] = 999 // left's last element, the source's third element, is right's first element
fmt.Println("left :", left)
fmt.Println("right :", right)
fmt.Println("source:", source)
}
left : [10 20 999] right : [999 40 50] source: [10 20 999 40 50]
left covers the first three elements, right covers the last three; the two overlap at the
third position (source[2]). When left[2] = 999 is written, the one memory position that
changes is exactly the one all three — left, right and source — share; this is why the
change is visible in all three. It does not matter how many variables point to the same
backing array, what matters is only which positions overlap.
The Same Call, Two Capacities, Two Results
Sharing up to this point was predictable: as long as a slice existed, every slice derived from
it shared the same backing array with it. append breaks this predictability, because it does
two different things depending on whether it exceeds capacity: if it does not, it writes to
the existing backing array and sharing continues; if it does, it allocates a new backing array
and sharing is cut off. The source text can be exactly the same in both.
CT5. — Both slices are filled with the same three elements, in the same order; the only
variable that creates the difference is capacity. Capacity is given explicitly as the second
argument to make so that the difference is not left to run-to-run chance.
// growth.go — the same append call, two capacities, two results
package main
import "fmt"
func add(s []int, value int) []int {
return append(s, value)
}
func main() {
roomy := make([]int, 3, 5) // two extra slots in capacity
copy(roomy, []int{1, 2, 3})
full := make([]int, 3, 3) // no extra room in capacity
copy(full, []int{1, 2, 3})
roomy2 := add(roomy, 4)
roomy2[0] = 100
fmt.Println("roomy (cap=5) after roomy:", roomy, " roomy2:", roomy2)
full2 := add(full, 4)
full2[0] = 100
fmt.Println("full (cap=3) after full:", full, " full2:", full2)
}
roomy (cap=5) after roomy: [100 2 3] roomy2: [100 2 3 4] full (cap=3) after full: [1 2 3] full2: [100 2 3 4]
The add function was called on both with the same source text: append(s, value).
roomy carried two extra slots in its capacity; append used that room, did not grow the
backing array, and the roomy2 it returned still points to the same backing array. This is
why roomy also changed when roomy2[0] = 100 was written — sharing continued. full, on
the other hand, was at full capacity; append allocated a new backing array, and full2
pointed to that new backing array. full2[0] = 100 this time changed only full2, full
stayed untouched.
This is the course’s clearest line falling into the open class: the language guarantees
that append will allocate a new backing array when capacity is exceeded, but whether the
result will be shared with the source cannot be read from the source code. The same line —
append(s, value) — continues sharing in one call, cuts it off in another; a reader cannot
say which it will be without knowing the capacity.
The place where this uncertainty most often causes confusion in practice is functions that
grow a slice. If a function adds elements to the slice it receives as a parameter with
append and does not return the result, the caller’s variable is never updated,
regardless of capacity — even when capacity would have been enough.
CT6. — buffer’s capacity here is deliberately kept ample (room for ten elements, three
elements will be added); the goal is to remove capacity shortage from the list of possible
explanations. Had capacity been kept tight, why the result did not change could be attributed
to two causes at once, and the measurement would become ambiguous.
// accumulate.go — append that does not return its result never updates the caller
package main
import "fmt"
func appendUnsafely(target []int, source []int) {
for _, v := range source {
target = append(target, v)
}
fmt.Println("target inside function:", target)
}
func main() {
buffer := make([]int, 0, 10) // an empty accumulator with ample capacity
appendUnsafely(buffer, []int{1, 2, 3})
fmt.Println("buffer at the caller :", buffer)
}
target inside function: [1 2 3] buffer at the caller : []
buffer‘s capacity was ten, more than enough for three elements — sharing could have
continued as in the previous measurement’s roomy example. But appendUnsafely assigns every
append result to its own local target variable and returns nothing; when the function
returns, buffer’s header has not changed at all, len(buffer) is still zero. Even capacity
being sufficient does not rescue sharing in this case, because the problem is not in the
backing array, it is that the updated header is never carried back to the caller.
copy and the Three-Index Slice Expression: Two Notations That Close the Gap
This unpredictability can be closed with two notations. The first is the copy built-in
function: it takes the target and the source as two slices with separate backing arrays,
copies the elements one by one, and returns the number of elements copied.
// copying.go — an independent copy with copy, a bounded capacity with the three-index slice
package main
import "fmt"
func add(s []int, value int) []int {
return append(s, value)
}
func main() {
source := []int{10, 20, 30, 40}
independent := make([]int, len(source))
count := copy(independent, source)
independent[0] = 7777
fmt.Println("copy count:", count, " independent:", independent, " source:", source)
bounded := source[1:3:3]
fmt.Println("bounded length=", len(bounded), " capacity=", cap(bounded))
bounded2 := add(bounded, 999)
bounded2[0] = 8888
fmt.Println("bounded2:", bounded2, " source:", source)
}
copy count: 4 independent: [7777 20 30 40] source: [10 20 30 40] bounded length= 2 capacity= 2 bounded2: [8888 30 999] source: [10 20 30 40]
After the call copy(independent, source), independent has its own backing array; changing
its first element does not affect source at all. CT7. — The returned count value is
not assumed anywhere, it is printed directly; copy copies the smaller of the target’s or
source’s length, and since both are four in this example, it copies four.
copy’s target and source do not have to be in two independent backing arrays; it works
correctly even if the two point to the same backing array. This is useful for shifting
elements within a single slice.
// shifting.go — copy also works correctly on overlapping ranges within the same backing array
package main
import "fmt"
func main() {
slice := []int{1, 2, 3, 4, 5}
shifted := copy(slice[0:], slice[1:4]) // shift the second to fourth elements one position left
fmt.Println("shifted count:", shifted, " slice:", slice)
}
shifted count: 3 slice: [2 3 4 4 5]
slice[1:4] and slice[0:] are two overlapping slices of the same backing array; copy
shifts the second through fourth elements one position to the left, and the last element
(5) stays in place, because the shift covered only the three elements copied. This
behavior is not chance, it is part of copy’s own promise: even when source and target
overlap, the result is one where the elements are copied correctly, not merely in
order. A loop written by hand, advancing index by index to do the same operation, could
write over elements early depending on the shift direction and corrupt the data; copy
does not carry this risk.
The second notation is the three-index slice expression: source[1:3:3] specifies not only
the start and end, but also the capacity with a third value. bounded‘s capacity here is
set equal to its length — no extra room is left. This is why the append call on bounded
certainly exceeds capacity and allocates a new backing array; when bounded2[0] = 8888 is
written, source stays untouched. In the earlier section’s full example, capacity being
exact was a coincidence (the three-element literal was built with exactly three capacity); the
three-index slice expression removes this from chance and turns it into a notational
decision — it locks capacity to the desired limit.
CT8. — The two closing notations serve two separate purposes: copy is used when a
completely separate copy of the data is wanted, while the three-index slice expression is used
when sharing the same backing array with the source is meant to continue, and only wants to
guarantee that a later append will cut that sharing off. The first ends sharing from the
start, the second decides in advance that growth will cut sharing off.
What these two notations have in common is that neither leaves capacity to the code reader’s
guess. copy removes capacity from the subject entirely — whatever the target’s own capacity
is, that is what gets used, with no relationship left to the source’s capacity. The
three-index slice expression, by contrast, does not zero out capacity, it narrows it:
normally the notation source[1:3] would inherit all the remaining capacity (up to the end of
the source), and the third index cuts off that inheritance, confining the slice to its own
size. Both arrive at the same outcome — making sure a later append will not touch the
source — but with different costs: copy requires a copying operation, the three-index slice
expression moves no data at all, it only bounds a number.
What the three sections measure comes down to a single sentence: slicing always shares;
append shares or does not, depending on capacity; copy and the three-index slice
expression turn sharing into a notational-level decision. Looking at the source text, the
answer to “does this line share” is known in advance in the third case; in the first two it is
not known, it is learned only by running.
Summary
- A slicing expression’s unwritten bounds are defined:
0is used when the start is unwritten, the slice’s length when the end is unwritten; this is an example that falls into the defined class of the Zero Values lesson’s binding 8/4/3 distribution. - A slice derived by slicing points to the same backing array as the source; writing to the derived slice also changes the source.
- As long as
appenddoes not exceed capacity, it writes to the existing backing array and sharing continues; once it exceeds capacity, it allocates a new backing array and sharing is cut off. The same source text can give two different results at two different capacities. copycopies elements one by one into a backing array independently owned by the target and returns the number of elements copied; it leaves no sharing with the source.- The three-index slice expression (
source[start:end:capacity]) explicitly bounds capacity and guarantees, at the notational level, that a laterappendwill separate from the source.
Next Step
The sharing measured in these two lessons was among ordered structures like arrays and slices; the next topic looks at key-value matching. A map is also a header and shares an underlying structure, but the question it asks is different: what happens when a key that does not exist is accessed, and does that answer arrive as a single value or two values? The next lesson separates these two — and also why iteration order is never printed directly.
To keep your progress and take notes, Log in
My notes
Log in to take notes.