Odin · slices

A slice, four levels deep

A slice []int is not the data. It is a tiny 16-byte handle — two fields, {data, len} — that views into a run of ints living somewhere else. Below, six ints sit in a backing array nums; each slice is just an address (where its view starts) plus a count (how many it covers). Pick a slice and watch which bytes of the handle change — and which ones don't.

Real header bytes, measured from a compiled Odin program (little-endian). The len field is stable; the data address differs every run, so it's shown as one representative run.
data : rawptr (8 bytes)
len : int (8 bytes)
backing array  nums : [6]int  — 48 bytes, the slice's handle never touches it
data — address of first viewed element len — how many elements zero (high bytes) elements this slice sees

Widths (size_of)

[6]int (the data) = 48
[]int (the handle) = 16
^int (bare addr) = 8

A []int is 16 bytes whether it views 2 ints or 2 million — it's the same address + count handle. Passing it copies those 16 bytes, not the run.

What you're seeing

Level 1 was what it is. Level 2 is why you'd reach for one: a slice is a cheap, mutable window. You hand a proc 16 bytes instead of copying the run — and because the handle points at the storage, a write through the slice lands on the backing array itself. This sharing-without-copying is called aliasing, and it's the whole reason slices exist.

Here are two slices over the same six-int backing array. mid = nums[1:4] sees indices 1–3; overlap = nums[2:5] sees 2–4. They share indices 2 and 3. Write 999 through one and watch where it shows up:

one backing array — nums : [6]int
mid = nums[1:4]
overlap = nums[2:5]

The mechanism: mid[1] is the same storage as nums[2], which is the same storage as overlap[0] — one location, three names. The write doesn't copy or "propagate"; it lands once, and every view reading that address sees it. That's why passing a []int into a proc lets the proc mutate the caller's data: the handle carries the address, so callee and caller are working the same bytes.

The counterfactual: if a proc only needs to read the run — sum it, print it — the aliasing is invisible and you still get the cheap 16-byte pass. You feel aliasing only when something writes. Reach for a slice when you want a write to stick in the caller's storage, or when copying the whole run would be wasteful. If you genuinely need an independent copy, that's a deliberate, separate act (slice.clone allocates fresh storage) — assignment alone never gives you one.

assigning a slice copies the handle, not the run b := a duplicates the 16-byte header — the same data pointer and len — so a and b are two handles onto one backing run. Measured: after a := nums[:], b := a, then b[0] = 777, you read a[0] as 777 and nums[0] as 777 too, and raw_data(a) == raw_data(b) is true. Copying a slice is copying where to look, never what's there.

Level 2 sold you on the window. Level 3 is the bill: the handle has no idea whether its backing storage is still alive. A slice borrows a lifetime it doesn't own — and Odin has no garbage collector and no general lifetime tracking, so keeping the backing valid is your job.

The footgun is the dangling slice: a handle whose backing run has already been reclaimed. Watch a proc return a slice of its own local array:

Where Odin helps — and where it doesn't. Odin has a targeted escape check that rejects the most obvious shape at compile time: returning a slice of a local. That exact program does not build —

leak :: proc() -> []int {
    local: [4]int = {1, 2, 3, 4}
    return local[:]   // a view into storage that's about to vanish
}

Error: It is unsafe to return a slice of a local variable ('local[:]')
    from a procedure, as it uses the current stack frame's memory

But that check guards one shape, not lifetimes in general. A slice that escapes indirectly — written through an out-parameter, tucked into a struct the caller keeps, taken into a growable buffer you then grow until it relocates, or into make-d storage you then delete — compiles silently and still dangles. The 16-byte handle doesn't flinch when its backing dies; the data pointer keeps pointing at the same address, which now holds whatever moved in next. Reading through it gives you stale values or a crash.

the rule that falls out of this A slice is valid exactly as long as its backing storage is valid — and "valid" is a fact about the storage, not about the handle. A slice into a local fixed array dies when the function returns. A slice into a growable buffer can die the moment you append and force it to relocate (Lesson 07). A slice into make-d memory dies when you delete. The compiler catches the one blatant case above; the rest are a discipline you carry — name who owns the backing and how long it lives before you hand out a view of it.

The last level is the payoff that emerges from "it's just an address + a count": because a slice owns no storage, you can carve a backing run into as many windows as you like, for free, and they all compose — while the half-open bounds stay machine-checked so a bad window is caught, not silently wrong.

Slicing is a[lo:hi], half-open: it includes lo, excludes hi, so len == hi - lo. And a slice of a slice is still a []int over the same backing — the bounds just compose by addition. From nums = {10,20,30,40,50,60}:

one backing run, many composed windows — measured

inner is a window of a window: tail = nums[3:] starts at index 3, then inner = tail[0:2] takes the first two of that — landing on nums[3:5]. Measured: raw_data(inner) == &nums[3] is true. Iterate it by reference (for &v in inner do v *= 2) and the doubling lands in the backing array: nums becomes [10, 20, 30, 80, 100, 60].

Each window costs nothing to make — no allocation, no copy, just a fresh 16-byte handle pointing into storage that already exists. The discipline that keeps this safe: the bounds are checked, and a bad one is a real error, not a quiet read of someone else's memory. Pick a way to break it:


    

The emergent payoff: a slice is so cheap and composable because it carries no storage — and that same fact is the bill from Level 3. One idea, two faces: "owns nothing" is what lets you mint a hundred zero-cost views over one buffer and pass each as a flat 16-byte handle; it's also why each view's life is tied to a backing run you must keep alive. The half-open bounds and the bounds check are what make slicing-everywhere safe to lean on: a window you can prove is in range, and one the runtime stops if you can't.

That's the arc: L1 a slice is a 16-byte {data, len} handle viewing storage elsewhere → L2 that handle aliases the backing, so a write through it sticks and a pass is just 16 bytes → L3 the bill is a borrowed lifetime the handle can't see (Odin guards the obvious dangle, nothing more) → L4 owning nothing is what makes windows free and composable, kept honest by checked half-open bounds.

probes reproduce with odin run … -file · widths, header bytes, outputs & the L3/L4 errors are real compiler output (claims/lessons/06-slices)