JavaRush /Courses /Go SELF /Copying as an ownership boundary: copy and “your own buf...

Copying as an ownership boundary: copy and “your own buffer”

Go SELF
Level 12, Lesson 4
Available

1. Why we even need an “ownership boundary” for a slice

When you are just starting to work with slices, it’s very tempting to think of them as standalone lists: “took a subslice — got a new thing.” But in Go it’s not like that, and there is a reason: the language saves memory and does not copy data unless you ask. The problem begins when your code psychologically expects independence, but in reality gets shared memory.

Imagine a real-life analogy: you and a friend rented an apartment (the backing array), and each of you got a “room” (a subslice). As long as you’re just looking and counting furniture, everything is fine. But as soon as you start “rearranging the wardrobes” (changing elements), it turns out it’s one apartment. Your neighbor suddenly got a different view from the window... because you moved a wall.

In practice, an “ownership boundary” is needed when:

  • you want to return a “piece” of data from a function, but you don’t want the caller to accidentally break your internals;
  • you want to keep a snapshot of the state that must not change even if the original slice changes;
  • you want to work safely with subslices in a “take — process — put back” style, without worrying about side effects.

Limiting cap via s[a:b:c] is a good protection specifically against “a bad append”, but it’s still the same backing array. Full independence is achieved only by copying.

2. copy(dst, src): what it does and how much it copies

There is good news about copy in Go: it’s built-in, simple, and fairly honest. There is also strict news: copy does not do magic and does not “grow” slices — it copies exactly as many elements as it can into an already existing destination.

The signature (in words) is: copy(dst, src) copies elements from src into dst and returns the number of copied elements. A classic pattern in the standard library looks like n := copy(p, b.slice) — the n value is then used to “advance” through the data.

The key rule that you should memorize like the multiplication table:

copy copies min(len(dst), len(src)) elements.

So if dst has length 0, there is nowhere to copy, even if cap is large. This is one of the most common traps; we’ll get to it soon.

Mini example: copy as much as fits

package main

import "fmt"

func main() {
	src := []int{10, 20, 30}
	dst := make([]int, 2) // len=2

	n := copy(dst, src)
	fmt.Println(n)   // 2
	fmt.Println(dst) // [10 20]
}

Here dst has length 2, so at most 2 elements can be copied.

Mini example: copy does not change length

package main

import "fmt"

func main() {
	src := []int{1, 2, 3}
	dst := make([]int, 0, 3) // len=0, cap=3

	n := copy(dst, src)
	fmt.Println(n)   // 0
	fmt.Println(dst) // []
}

This looks “unfair” only for the first five minutes. Then you realize: Go forces you to explicitly manage how many elements “count as part of the slice” (i.e., are included in len). Capacity (cap) is just spare memory, but while len=0, there are no “official” elements.

A small diagram

To avoid confusion, it helps to keep a mini-table like this in mind:

What limits it What it affects
len(dst)
how much
copy
can write
len(src)
how much there is to copy at all
cap(dst)
how much you could have via reslice/append, but
copy
does not use it

3. The “own buffer” pattern: make + copy

Now to the most practical part: how to make sure we get “our own buffer,” i.e., a new backing array. There is a very simple and canonical pattern here. It is so canonical that you can even see it in discussions about the language’s evolution: allocate a new buffer, copy elements into it, and then work with it.

The pattern is:

  1. create dst := make([]T, len(src))
  2. do copy(dst, src)
  3. return/use dst

Example: clone a slice entirely

package main

import "fmt"

func cloneInts(src []int) []int {
	dst := make([]int, len(src))
	copy(dst, src)
	return dst
}

func main() {
	a := []int{1, 2, 3}
	b := cloneInts(a)

	b[0] = 99
	fmt.Println(a) // [1 2 3]
	fmt.Println(b) // [99 2 3]
}

Now a and b are independent: they have different backing arrays.

Example: for strings it’s the same

package main

import "fmt"

func cloneStrings(src []string) []string {
	dst := make([]string, len(src))
	copy(dst, src)
	return dst
}

func main() {
	names := []string{"Ann", "Bob"}
	safe := cloneStrings(names)

	safe[1] = "Bobby"
	fmt.Println(names) // [Ann Bob]
	fmt.Println(safe)  // [Ann Bobby]
}

An important note for beginners: strings in Go are immutable by themselves, but a slice of strings is mutable (you can replace a slice element, i.e., the value/reference stored in a cell). So an independent copy of a string slice is genuinely useful.

4. Copy a subslice, not the whole slice

Often in real life you don’t want to copy everything: you only need a segment. For example, “the last 3 user commands,” “the first 5 numbers,” “a token subrange” (we are still talking about slices, not strings/runes). And here it’s important not to confuse two different actions: slicing and copying.

Slicing sub := s[a:b] creates a new window onto old memory. Copying creates new memory. So the correct sequence is: first pick the range, then make an own buffer for it.

Example: take a piece and make it independent

package main

import "fmt"

func clonePart(src []int, a, b int) []int {
	part := src[a:b]
	dst := make([]int, len(part))
	copy(dst, part)
	return dst
}

func main() {
	s := []int{10, 20, 30, 40}
	x := clonePart(s, 1, 3) // [20 30]

	x[0] = 999
	fmt.Println(s) // [10 20 30 40]
	fmt.Println(x) // [999 30]
}

Here x is an independent copy of the range [1:3].

Why s[a:b:c] does not replace copy

After a full slice s[a:b:c], the subslice gets a limited cap, and append can no longer silently overwrite the tail of the original array. But if you do sub[0] = ..., you will still change the original data. Capacity limiting is a “fence against growth,” while copy is “moving into a separate apartment.”

5. len matters more than cap: why copy doesn’t “fill capacity”

At this point beginners often have a small personal drama: “But I allocated cap! Why did copy copy nothing?!” Calm down. This is not a bug and not trolling, it’s a logical model: len is the size of the logical data, cap is a technical memory reserve.

If you have len(dst) == 0, it means you officially stated: “there are no elements in the slice right now.” copy respects that statement and doesn’t pretend that you “actually meant something else.”

Wrong version

package main

import "fmt"

func main() {
	src := []int{1, 2, 3}
	dst := make([]int, 0, len(src)) // len=0

	copy(dst, src)
	fmt.Println(dst) // []
}

Correct version #1: allocate the required length immediately

package main

import "fmt"

func main() {
	src := []int{1, 2, 3}
	dst := make([]int, len(src))

	copy(dst, src)
	fmt.Println(dst) // [1 2 3]
}

Correct version #2: allocate cap, then reslice

Sometimes architecturally it’s convenient to allocate a buffer “with room to spare” first, and only then decide how many elements it will contain. In that case, you reslice to the needed length and only then copy.

package main

import "fmt"

func main() {
	src := []int{1, 2, 3}

	dst := make([]int, 0, len(src))
	dst = dst[:len(src)] // now len=3

	copy(dst, src)
	fmt.Println(dst) // [1 2 3]
}

This option is a bit more “engineering-oriented,” but it helps you understand the main point: copy works by len, while cap is just a ceiling that you can use via reslice/append.

Choosing an API strategy: view, cap-limit, or copy

Here it’s important not to “turn copying into a religion” (yes, that happens too). A copy is not always good and not always needed. Sometimes you want a view, because it’s fast and convenient. Sometimes you want protection from append (limit cap). And sometimes you want full independence (a copy).

Slices are a window onto an array. So your choice is essentially: do we want to share a window with someone else, or do we want our own room?

It helps to think like this:

Strategy How it looks What it guarantees What it does NOT guarantee
Alias (view)
sub := s[a:b]
fast, no allocations element changes are visible to both sides
Limit
cap
sub := s[a:b:b]
append
won’t “leak” into the tail of the original array
element changes are still shared
Copy (own buffer)
dst := make(...); copy(dst, sub)
full data independence requires memory allocation and copying

In good programs, you choose a strategy consciously based on the function’s meaning. If a function is called ViewLastN, it has the right to return an alias. If it’s called SnapshotLastN, the user will expect a copy. Naming is almost a contract.

6. Example: “To-Do List” and a safe Last(n)

Now we’ll make a small example that you can “mentally” continue later in the course. Suppose we have a console program “To-Do List”: it stores tasks in a slice of strings. We want to be able to get the last n tasks to display in the UI or for further processing. And here an important design question appears: return a view or a copy?

Let’s start with the minimal data model — just []string. No structs, files, or databases: we are practicing slices specifically.

Step 1: adding tasks

package main

import "fmt"

func addTask(tasks []string, title string) []string {
	return append(tasks, title)
}

func main() {
	var tasks []string
	tasks = addTask(tasks, "Buy bread")
	fmt.Println(tasks) // [Buy bread]
}

So far, it’s simple: append returns a new slice, and we save it.

Step 2: the “dangerous” LastView version

package main

func lastView(tasks []string, n int) []string {
	if n > len(tasks) {
		n = len(tasks)
	}
	return tasks[len(tasks)-n:]
}

This function is fast and “allocation-free.” But if someone later does result[0] = "...", they will change the original data, because this is a view.

Step 3: the “safe” LastSnapshot version

Here we apply today’s tool: copy as an ownership boundary.

package main

func lastSnapshot(tasks []string, n int) []string {
	if n > len(tasks) {
		n = len(tasks)
	}
	part := tasks[len(tasks)-n:]
	dst := make([]string, len(part))
	copy(dst, part)
	return dst
}

And let’s verify that this is indeed a protection.

package main

import "fmt"

func main() {
	tasks := []string{"A", "B", "C", "D"}

	view := lastView(tasks, 2)
	snap := lastSnapshot(tasks, 2)

	view[0] = "XXX"
	snap[1] = "YYY"

	fmt.Println(tasks) // [A B XXX D]
	fmt.Println(view)  // [XXX D]
	fmt.Println(snap)  // [C YYY]
}

You can see two ideas right away:

  • view changed the original tasks (because of the shared backing array).
  • snap did not change the original tasks (because it has its own buffer).

That is the “ownership boundary”: we basically said “this piece of data is mine now, and I promise that external changes won’t leak in here, and my changes won’t leak out.”

A small flowchart

flowchart TD
  A[Source backing array] -->|tasks| B[tasks: len,cap]
  A -->|lastView| C[view: another window
shared memory] A -->|part| D[part: subslice] D -->|copy to a new array| E[dst: own backing array] E -->|lastSnapshot| F[snap: independent data]

The meaning of the diagram is simple: lastView gives a new window onto old memory, while lastSnapshot gives a new window onto new memory.

7. Typical mistakes when copying slices

Mistake #1: “I made a copy” via t := s. This is not a copy of data; it’s a copy of the slice header (pointer/len/cap). The elements themselves remain shared, because the backing array is the same. If you want “your own buffer,” you need make + copy.

Mistake #2: you allocated cap, but forgot about len, and copy copied zero elements. This is that classic trap with make([]T, 0, n). copy looks at len(dst), not cap(dst). If you need to copy, do make([]T, len(src)) or reslice to the needed length before copying.

Mistake #3: hoping that limiting cap via s[a:b:c] makes data independent. A three-index slice really does protect you from an unexpected append, but element changes (sub[i] = ...) still go into the shared backing array. It’s a protection from growth, not a broken link.

Mistake #4: copying “the wrong range” because of off-by-one. It’s very easy to miss by one index: you wanted the last 3 elements but took 2 or 4. A reliable trick is to temporarily print a, b, len(part), and part itself before copy. And remember that a slice is a half-open interval [a, b).

Mistake #5: copying “just in case” in a hot loop and then being surprised by performance. Copying costs work and memory. Sometimes a view really is enough. A good style is to first make it correct and predictable (often via a copy at the boundary), and optimize only when there is a real need and you understand exactly where the bottleneck is.

1
Task
Go SELF, level 12, lesson 4
Locked
Duplicate list
Duplicate list
1
Task
Go SELF, level 12, lesson 4
Locked
Capacity not length
Capacity not length
1
Task
Go SELF, level 12, lesson 4
Locked
View and snapshot
View and snapshot
1
Task
Go SELF, level 12, lesson 4
Locked
Last elements
Last elements
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION