Skip to content
academia.sh

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: 0 is 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 append does 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.
  • copy copies 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 later append will 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close