Lesson 04 / 14
Slices
A slice views a specific range of a string or a growable array without taking ownership: the slice gets used, the slice ends, but the source never ends. A slice's length derives from the source, and an empty slice is valid; the source cannot be changed while the slice lives, and a change goes through without a problem once the slice ends.
Published Updated
In this lesson
- Shows by running it that a slice views a string or a growable array without taking ownership, and that the source never ends while the slice lasts
- Verifies that a slice's length derives from the source and that an empty slice is valid
- Proves that the source cannot be changed while the slice lives, and that a change goes through without a problem once the slice ends
Contents
The previous lesson measured references lent out over a value’s entirety. This lesson applies the same access-without-ownership principle to only a part of a value: a slice, a kind of reference that views a contiguous range of a string or a growable array without taking ownership. What is measured is again a value’s ending, not a running unit stopping — the Concurrency course’s goroutine question stays outside this course’s axis.
The concept of a slice itself had already been established in the Concurrency
course — a view representing a contiguous sub-range of an array and carrying its own
length. What this lesson measures is that Rust builds a slice with a range syntax
([start..end]), and that the previous three lessons’ rules (ownership is not taken,
the source does not end, a borrow’s life cannot exceed the source) apply here exactly
as they were.
The previous three lessons assumed a reference always pointed to a single value:
&Trace, &mut Trace. A slice loosens this assumption — a reference can now point to
several contiguous values at once, but in doing so it changes none of the rules of
ownership, scope, or the shared/mutable distinction. What is measured in this lesson is
that the rules stay the same; only what gets borrowed changes.
Viewing Part of a Value
A slice is a kind of &T: &[Trace] is a view taken from a growable array, &str is
a view taken from a string. Both point to the source’s own data — they do not copy the
data — and separately carry their own length. When a slice is taken the source’s owner
does not change; only temporary access to that range is set up.
The range syntax has the form [start..end]: start is included, end is excluded.
In its simplest form, with the ownership question never coming into play at all, the
range itself reads as follows.
// basic_slice.rs — slice syntax: the start..end range
fn main() {
let numbers = [10, 20, 30, 40, 50];
let middle = &numbers[1..4];
println!("source: {:?}", numbers);
println!("slice: {:?}", middle);
println!("slice length: {}", middle.len());
}
source: [10, 20, 30, 40, 50] slice: [20, 30, 40] slice length: 3
&numbers[1..4] views three elements starting at index 1, up to (not including) index
4 — 20, 30, 40. Because numbers carries a copyable type like i32 here, the
ownership question does not show up; the real question arises when the source itself
carries a type that cannot be copied and implements Drop — exactly the situation the
rest of this lesson measures. The [1..4] syntax itself does not change numbers’s
type; numbers is still a fixed-length array, while middle is a separate type — a
slice with no length of its own, one that derives from the source.
Measurement: The Source Never Ends
The measurement below takes a slice viewing the first two elements from a growable
array holding three Trace values, uses the slice, closes the slice’s scope, and then
changes the source.
// trace.rs — a measurement helper that records a value's ending
use std::cell::RefCell;
use std::rc::Rc;
pub type Ledger = Rc<RefCell<Vec<String>>>;
pub struct Trace {
pub name: String,
ledger: Ledger,
}
impl Trace {
pub fn new(name: &str, ledger: &Ledger) -> Trace {
Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
pub fn new_ledger() -> Ledger {
Rc::new(RefCell::new(Vec::new()))
}
pub fn read_log(ledger: &Ledger) -> Vec<String> {
ledger.borrow().clone()
}
// slice_life.rs — a slice views without taking ownership; it does not end the source, but it locks it
mod trace;
use trace::{Trace, new_ledger, read_log};
fn main() {
let ledger = new_ledger();
let mut list = vec![
Trace::new("one", &ledger),
Trace::new("two", &ledger),
Trace::new("three", &ledger),
];
let before = read_log(&ledger).len();
{
let view = &list[0..2];
println!("slice length: {}, first name: {}", view.len(), view[0].name);
println!("ledger while the slice lives: [{}]", read_log(&ledger)[before..].join(", "));
}
println!("ledger after the slice ends: [{}]", read_log(&ledger)[before..].join(", "));
list.push(Trace::new("four", &ledger));
println!("list changed because the slice ended, length: {}", list.len());
}
slice length: 2, first name: one ledger while the slice lives: [] ledger after the slice ends: [] list changed because the slice ended, length: 4
view only views list‘s first two elements, but list is still the owner of all
three elements. The ledger stays empty both while the slice lives and after the
slice’s scope closes — none of the values one, two, and three end, because the
slice never owned them, it only looked at them. list itself never ends in this
measurement (because the code does not close at the end of the file); the only ending
seen is the slice’s own scope closing — and that writes nothing to the ledger either,
because a slice of type &[Trace] does not implement Drop.
One detail deserves attention here: view only views the first two elements (one,
two); the third element (three) is left outside the slice. Even so, the measurement
shows that list in its entirety still sits in its owner — three, which the slice
does not cover, and one and two, which it does, are unaffected in the same way. A
slice only opens a window to read a subset of the source; it does not interfere in
any way with the ownership or life of the rest of the source.
The view[0].name access makes this concrete too: view[0] produces a &Trace
pointing to the slice’s first element (not ownership, a new borrow), and .name is
read through that borrow. Three separate borrows are nested in the same line — from
list to view, from view to view[0], from view[0] to .name — but none of
them asks for ownership, all of them trace back to the same underlying Trace values.
OR10. A slice, just like an ordinary &T reference, is not the owner of the
elements it points to. When its scope closes, only it (the slice’s own start, length
pair) disappears; none of the elements are affected by this. The previous lesson’s rule
that “a reference does not end the source” applies here exactly as it was, only
visibility is over a contiguous range rather than a single value.
Boundary Measurement: A Slice Locks Its Source
The list.push(...) line runs after view‘s scope closes. This ordering is
required: if the same line had been added while view was still in scope, the compiler
would reject it. view is a shared borrow of list, and the push call asks for a
mutable borrow to change list — the Borrowing lesson’s “one writer or many readers”
rule applies here too, only what it borrows is no longer the whole value but a part of
it.
OR11. A slice locks its source’s entirety, not just the range it views. Even
though view only views the first two elements, it also blocks adding an element
anywhere in list (at the end, for example) — because an insertion can rearrange a
growable array’s own internal layout and invalidate the positions the slice points to.
The constraint is not limited to the elements the slice covers; it spreads across the
source as a whole.
This might look, at first glance, like it contradicts the statement that “a slice views only part of the source” — the part viewed is small, but the surface locked is large. There is no contradiction: a slice only grants permission to read the elements in its own range, but because any change to the source’s own layout (how many elements it holds, where the elements sit) could break the assumption the slice relies on, that change can never be started while the slice lives. What is viewed is narrow; what is protected is broad.
The accepted code already preserves this order: the slice is used and let go within
its own narrow scope first, and push is only called afterward, at a point where
list has sole access. Trying to write the same job in reverse order — changing first,
then continuing to read the old slice — does not compile at all.
What this constraint prevents is concrete: push can reallocate a growable array’s
backing storage when needed — as the array grows, all its elements can move to a new
location. If view pointed to the old locations and push triggered this move, view
would end up pointing to a region of memory that was no longer valid. The compiler
shuts this scenario down from the start by allowing no call that changes the source
while the slice lives — it does not even need to know whether push will actually
reallocate; the rule is the same for every mutating call.
The same constraint applies to reading a single element too: a direct index like
list[2] causes no trouble while view is in scope, because it also only reads — two
shared borrows can stand together. Trouble only starts when a change is requested
at the same time.
A Slice Passes to a Function Without Moving the Source
The Borrowing lesson’s read function took a single value without asking for
ownership. The same mechanism applies to a slice too: if a function writes its
parameter as &[Trace], it can accept both the source’s entirety and just a range of
it, without asking for ownership.
// slice_param.rs — a function accepting a slice processes the source without moving it
mod trace;
use trace::{Trace, read_log, new_ledger};
fn join_names(slice: &[Trace]) -> String {
let mut result = String::new();
for (i, trace) in slice.iter().enumerate() {
if i > 0 {
result.push('+');
}
result.push_str(&trace.name);
}
result
}
fn main() {
let ledger = new_ledger();
let list = vec![
Trace::new("a", &ledger),
Trace::new("b", &ledger),
Trace::new("c", &ledger),
];
println!("full list: {}", join_names(&list));
println!("first two: {}", join_names(&list[..2]));
println!("ledger after the calls: [{}]", read_log(&ledger).join(", "));
}
full list: a+b+c first two: a+b ledger after the calls: []
The join_names function can be called with both &list (the entire source) and
&list[..2] (a sub-range) — because the parameter type is &[Trace], both fit the same
function. &list is converted into a slice here: a reference to the growable
array’s entirety automatically corresponds to a slice covering it start to end. The
ledger stays empty after both calls too — join_names never owned list’s elements,
it only read them. This is the concrete example of a function accepting both the
whole and a part with the same signature, without asking for ownership.
A Mutable Slice Changes the Elements It Covers
The slices seen so far have always been shared (&[Trace]). A slice can also be taken
as &mut [Trace], and in that case it also carries write permission on the elements it
covers — the Borrowing lesson’s shared/mutable distinction applies here exactly as it
was.
// mutable_slice.rs — a mutable slice can change the elements it covers
mod trace;
use trace::{Trace, new_ledger, read_log};
fn main() {
let ledger = new_ledger();
let mut list = vec![
Trace::new("x", &ledger),
Trace::new("y", &ledger),
Trace::new("z", &ledger),
];
{
let view = &mut list[0..2];
for trace in view.iter_mut() {
trace.name.push_str("-new");
}
}
println!("names after the change: {}, {}, {}", list[0].name, list[1].name, list[2].name);
println!("nothing has ended, ledger: [{}]", read_log(&ledger).join(", "));
}
names after the change: x-new, y-new, z nothing has ended, ledger: []
view here views list‘s first two elements as mutable; trace.name.push_str(...)
inside the loop really changes these two elements’ name field. The third element
(z) is left outside the slice, so it does not change — the output confirms this. No
value ends when the scope closes, because a mutable slice, just like a shared slice,
does not take ownership; it only carries write permission over that range.
In this example too there is only one mutable slice at a time; while view was in
scope, no other access to list (shared or mutable) could have been opened. Once
view‘s scope closes, direct indexing like list[0].name can be freely used — just as
with the push example seen in the Boundary Measurement section, the constraint only
holds for the slice’s own scope.
Length Comes From the Source, and an Empty Slice Is Valid
A slice’s length does not carry information on its own — it is computed from the source. The same range syntax applies on a string too.
// string_slice.rs — a slice's length derives from the source, and an empty slice is valid too
fn main() {
let text = String::from("value");
let full = &text[..];
let empty = &text[0..0];
println!("source length: {}", text.len());
println!("full slice length: {}", full.len());
println!("empty slice length: {}", empty.len());
println!("is the empty slice valid: {}", empty.is_empty());
}
source length: 5 full slice length: 5 empty slice length: 0 is the empty slice valid: true
&text[..] views the entire range, while &text[0..0] views an empty range covering
no characters at all. full.len() comes out the same as the source’s because the range
runs start to end; empty.len() is zero, but empty is not an invalid value — it is a
valid slice in its own right, it just covers nothing. The is_empty() call confirms
this: an ordinary method called on an empty slice runs without hitting any special
case.
The number text.len() gives here is the string’s byte length — this, that a string is
itself a byte sequence and diverges from the character count, was already taken up in
the Collections lesson. What this lesson contributes is separate: how the range itself
is built and when it counts as valid. Because text‘s content carries only ASCII
characters, the byte length coincides with the character count here; the difference
between the two is not this lesson’s subject.
OR12. A slice having start == end is not an error condition, it produces a valid
slice of zero length. This lets a function return an empty range as, for example, the
“matching part,” and lets the caller handle it without having to treat it as a separate
special case. len(), is_empty(), even iterating with a loop (running zero times)
work without a problem on an empty slice — even when the source itself is not empty.
The three questions borrowing has carried across four lessons give the same answers
here too: does the source end (no), is the life extended (no, a slice’s scope can never
exceed the source’s), and does the shared/mutable distinction apply (yes — a
&mut [Trace] slice could also have been taken, and its constraints would have been
identical to the mutable reference in the Borrowing lesson). A slice brings no new
answer to these three questions; the only new thing it has is the size of the
range it views — a contiguous set instead of a single value.
Summary
- A slice (
&[T],&str) views a contiguous range of the source without taking ownership; the source itself never ends because of the slice — the source’s life ends at its own scope end. - A slice locks not just the range it points to, but the source’s entirety — the source cannot be changed while the slice lives, and a change goes through without a problem once the slice ends.
- A slice’s length derives from the source; a slice does not carry independent length information on its own.
- A slice with
start == endis valid and carries zero length; an empty slice is not a special case. - Because a slice is a reference too, all the rules of the previous two lessons (the shared/mutable distinction, a scope never exceeding the source) apply here as well.
- Writing a function parameter as
&[T]or&mut [T]lets that function accept both the source’s entirety and just a range of it, with the same signature, without taking ownership.
Next Step
Across these four lessons, borrowing’s rules were measured one by one — the whole value, a part, shared or mutable; each time the source did not end, the life did not exceed the source, and the one-writer-or-many-readers constraint held. These rules stayed the same across four separate lessons without ever conflicting, only the surface they applied to changed. The next lesson turns to the mechanism that enforces these rules itself: what exactly does the borrow checker test at compile time, and what kind of choice does this check leave between different pieces of code that do the same job?
To keep your progress and take notes, Log in
My notes
Log in to take notes.