Scope is about names, not values¶
Level: 201 · working knowledge
One line: One word gets asked three questions — when can I still write this name, when is the value freed, and when does a borrow stop mattering — and the three answers arrive at three different moments, which is why "it goes out of scope" explains a drop, a compile error and an unlocked critical section differently.
"Out of scope" is the phrase every Rust explanation reaches for, and it is doing at least three jobs. A name's scope is lexical: it runs from its let to the closing brace, and the compiler settles it by reading the source. A value's death is an event: usually at the end of the owning scope, but not if it was moved, not if it was a temporary, and not in the order you expect if it is a struct field. A borrow's life is neither: it ends at its last use, which is often several lines above the brace.
Conflating them is not a vocabulary problem. It is why a lock looks held when nothing holds it, why a borrow error moves when you move a println!, and why a "cleanup" struct runs its cleanup in the wrong order.
Three questions, one word¶
| The question | What answers it | When it happens |
|---|---|---|
| Can I write this name here? | Scope — lexical, from the let to the enclosing brace |
Fixed at compile time; nothing at run time can change it |
| When is this value freed? | Drop — the owner's scope end, in reverse declaration order | Run time, and movable: drop(x), a move, a temporary, or let _ all shift it |
| Is this borrow still in my way? | The borrow region — from the & to the borrow's last use |
Compile time again, but a shorter region than the name's scope |
The rest of this page is those three rows, each proved by a program rather than asserted. The whole verified run is below.
A name ends at the brace; its value need not¶
A block ends every name declared in it. It does not end the values — one of them can leave:
let carried = {
let _scratch = Tracked("scratch");
let leaving = Tracked("carried out");
leaving // the VALUE leaves; the name does not
}; // `_scratch` dies here; `leaving` did not
Two names ended and one value ended, at the same brace — the brace being an expression as well as a scope is what lets the value out. That is the whole distinction, and the run below prints it: drop: scratch lands at the brace, carried out survives to be dropped later under a different name.
This is the same fact a shadow does not drop is about, seen from the other side — there, the name goes and the value stays put; here, the name goes and the value leaves.
The borrow that ends early¶
Ask most people when a borrow ends and they will say "at the brace". It ends at its last use:
let mut orders = vec!["alpha"];
let first = &orders[0]; // the borrow starts
println!("{first}"); // ...and ends here
orders.push("beta"); // legal: nothing is borrowed any more
Move that println! below the push and the same four lines stop compiling — error[E0502], with the note "immutable borrow later used here" pointing at the line you moved. Nothing about the braces changed. What changed was where the borrow was last used, which is the entirety of its life.
This is non-lexical lifetimes (NLL), and it is why so much pre-2018 advice about "adding a block to end the borrow early" is now unnecessary work: the borrow already ended. The block is still the right tool when the borrow's users are a group of lines rather than one, and it is still required when the value is returned or stored somewhere that outlives the function.
What "goes out of scope" actually schedules¶
Scope end is the default moment a value dies, and five ordinary things move it. The program below runs all of them:
(There is a sixth, and it is missing here because it is not a scope event at all: an assignment frees whatever the location was holding, so a value can die on a line with no brace in sight — Assignment drops the old value.)
Verified output of scope_is_about_names.rs — regenerated by tools/run_examples.py, never hand-typed.
──── 1. A name ends at the brace; its value need not
inside: two names, two values
drop: scratch
after: the block is over, and that value is alive as `carried`
after: `leaving` and `_scratch` are not names here —
error[E0425]: cannot find value `leaving` in this scope
So the block ended two names and one value. They are not one event.
drop: carried out
──── 2. A borrow ends at its last use, not at the brace
first = alpha <- and ends here, at its last use
orders = ["alpha", "beta"] <- mutated afterwards, same block, no error
Move that first println! BELOW the push and it stops compiling:
error[E0502]: cannot borrow `orders` as mutable because it is
also borrowed as immutable
| let first = &orders[0];
| ------- immutable borrow occurs here
| orders.push("beta");
| ^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
| println!("{first}");
| ----- immutable borrow later used here
No brace moved. What moved was the last use — which is the whole
of the borrow's life, and it is shorter than the name's.
──── 3. When each value actually dies
two locals, and the brace is next:
drop: local declared second
drop: local declared first
^ reverse declaration order — the last one declared dies first
one struct, two fields, and the brace is next:
drop: field declared first
drop: field declared second
^ DECLARATION order. The reverse rule is about locals, not fields
temporary: an unnamed temporary
drop: an unnamed temporary
^ dropped at the end of the statement that built it, not the block
drop: let _ = …
`let _` binds nothing, so the value died on that line
`let _kept` binds normally; it is still alive, until:
drop: let _kept = …
drop: drop(early)
drop(x) is not a keyword and not magic: it takes x and returns ()
──── 4. Held, or not: `try_lock` answers what style cannot
after `let _ = counter.lock()`: try_lock succeeds = true
after `let _guard = counter.lock()`: try_lock succeeds = false
Same thread, same mutex, one identifier apart, and `try_lock` can
tell them apart. The first is an unlocked critical section that
reads exactly like a locked one — which is why rustc DENIES it
by default, and why the line above needs an #[allow] to exist.
But the lint knows std's locks, not the pattern:
drop: a hand-rolled guard, released early
no error, no warning — the value is already gone
──── 5. Some names are in scope before they are written
limit() = 7 <- called above its own definition
A `let` cannot do that. Read one above its own line and you get
error[E0425]: cannot find value `limit` in this scope
Items are in scope for the entire block; a binding's scope starts
at its `let` and runs to the brace. Same word, two different rules.
Read the third block line by line — every row of it is a rule people get wrong:
- Locals drop in reverse declaration order. The last one declared is the first to die.
- Struct fields drop in declaration order. The reverse rule is about locals, not fields. Two values that dropped in the right order as locals will flip the moment you move them into one struct, and nothing warns.
- A temporary drops at the end of the statement that built it, not at the end of the block. (
matchandif letscrutinees are the interesting case, and edition 2024 changed when theirs drop — seeif let.) let _ = value;drops it immediately, because_is a pattern that binds nothing.let _value = …;binds normally and keeps it to the brace. The underscore in front is a warning suppressant; the bare underscore is a different statement.drop(x)is an ordinary function that takes ownership and returns(). It is not a keyword and it does not "delete" anything — the value dies because it was moved into a function that ends.
The one _ the compiler refuses¶
The let _ rule above is the one with teeth, so Rust special-cases the worst instance of it. Write it on a std lock and you do not get a warning, you get an error:
error: non-binding let on a synchronization lock
| let _ = counter.lock().unwrap();
| ^ this lock is not assigned to a binding and is immediately dropped
|
= note: `#[deny(let_underscore_lock)]` (part of `#[deny(let_underscore)]`) on by default
Deny by default, in rustc itself — no clippy, no flag. The run above needs an #[allow] to demonstrate the bug at all, and with the allow in place try_lock answers the question that style cannot: after let _ = counter.lock() a second lock succeeds, because nothing is held. After let _guard = counter.lock() it fails. Same thread, same mutex, one identifier apart.
Note what the lint covers, though: std's locks, not the pattern. Your own RAII guard — a transaction, a span, a file lock, a permit — gets no diagnostic whatsoever, as the last two lines of that block show. See lock poisoning for what a held guard costs a second thread, and what a warning is asking for the unused variable that suggests this spelling in the first place.
Names that were never values¶
Scope is about names, and not every name is a binding. Items — fn, struct, const, use — are in scope for the entire enclosing block, including above the line that declares them:
fn main() {
println!("{}", limit()); // fine: the item is in scope for the whole block
fn limit() -> u32 { 7 }
}
A let cannot do that; reading one above its own line is error[E0425]: cannot find value … in this scope. So "in scope" means two different things in one block, and the difference is not stylistic: a binding's scope starts after its own initializer, which is exactly why let x = x + 1; reads the previous x instead of being a self-reference — the mechanism behind every shadow.
The same applies to use: it is an item, so a use written at the bottom of a function still applies to the whole function. Writing one inside a small block is the idiomatic way to keep a noisy trait import from leaking into the rest of the file.
If you are coming from another language¶
- Python. Scope is per-function, not per-block: a name bound inside an
iforforis still there afterwards, which is the opposite of the brace rule. And nothing is scheduled — CPython frees on the last reference, sodel xis notdrop(x); it removes one reference and the object may well survive. The construct that actually transfers iswith:with lock:is the_guardbinding, and the reason Python needs a dedicated statement is that it has no scope-end event to hang the release on. - ABAP. There is no block scope at all — a
DATAdeclared inside anIFis visible for the whole routine, and lives until the routine ends — so the "when does it die" question mostly does not arise, and neither does the discipline. The closest familiar shape is that aLOOPwork area keeps its last value afterENDLOOP, which in Rust would be a name that no longer exists. - C++. The closest relative by far: RAII, destructors at scope end, locals destroyed in reverse order, members destroyed in reverse declaration order — and that last one is the trap for a C++ reader, because Rust drops fields in declaration order. Same brace, opposite sequence.
Practice¶
Time three things you cannot see. Answer each of these by printing, never by reasoning about it:
- A
Reportowning aFileHandleand aTotals, each with aDropthat announces itself. Predict which announcement comes out first, then run it. If you predicted reverse order, fix the sequence without adding a statement. - A guard type that counts how many of itself are alive. Bind one with
let _ = …and print the count from inside the "critical section"; bind it withlet _g = …and print it again. Then swap your guard for astd::sync::Mutexand try to compile the first spelling at all. - Take
let leader = &standings[0]; standings.push(9); println!("{leader}");and make it compile three ways, none of them.clone(). Say which one you would ship, and what each of the other two changed about the program's meaning.
Worth getting wrong on purpose: fix (1) by writing a manual impl Drop for Report that drops the fields in the order you want. It works, it is more code, and it puts the ordering somewhere nobody looking at the struct will see it.
Solution
scope_is_about_names_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: three bugs about *when*, none of them about *what*.
//!
//! Part 1 is a report that writes its totals after closing the file it was
//! writing them to, because struct fields drop in declaration order.
//! Part 2 is a guard released one identifier early, and the counter that
//! proves the critical section ran unprotected.
//! Part 3 is a borrow that outlives its last use, fixed three ways, none of
//! which is `.clone()`.
//!
//! rustc --edition 2024 scope_is_about_names_kata.rs -o /tmp/scopek && /tmp/scopek
use std::cell::Cell;
use std::rc::Rc;
fn banner(title: &str) {
println!("\n──── {title}");
}
// ---------------------------------------------------------------- Part 1 ---
struct FileHandle(&'static str);
impl Drop for FileHandle {
fn drop(&mut self) {
println!(" [{}] file closed", self.0);
}
}
struct Totals(&'static str);
impl Drop for Totals {
fn drop(&mut self) {
println!(" [{}] totals written", self.0);
}
}
/// The bug: `file` is declared first, so it is dropped first.
struct BrokenReport {
_file: FileHandle,
_totals: Totals,
}
/// The fix, and it is only a reordering: what must happen first is declared first.
struct FixedReport {
_totals: Totals,
_file: FileHandle,
}
fn the_report_that_closed_too_early() {
banner("1. Fields drop in DECLARATION order, not in reverse");
println!(" broken — `_file` first, so the file closes before the write:");
{
let _report = BrokenReport {
_file: FileHandle("broken"),
_totals: Totals("broken"),
};
}
println!(" fixed — `_totals` first, and nothing else changed:");
{
let _report = FixedReport {
_totals: Totals("fixed"),
_file: FileHandle("fixed"),
};
}
println!(" The reverse-order rule you learned for locals is not the rule");
println!(" for fields. Two locals in the same order would have worked;");
println!(" moving them into one struct silently flipped the sequence.");
}
// ---------------------------------------------------------------- Part 2 ---
/// A hand-rolled guard: it counts itself in on creation and out on drop.
struct Guard {
held: Rc<Cell<u32>>,
}
impl Drop for Guard {
fn drop(&mut self) {
self.held.set(self.held.get() - 1);
}
}
struct Registry {
held: Rc<Cell<u32>>,
}
impl Registry {
fn new() -> Self {
Registry {
held: Rc::new(Cell::new(0)),
}
}
fn lock(&self) -> Guard {
self.held.set(self.held.get() + 1);
Guard {
held: Rc::clone(&self.held),
}
}
}
fn the_guard_released_one_character_early() {
banner("2. `let _` is not a name, so it holds nothing");
let registry = Registry::new();
{
let _ = registry.lock();
println!(
" let _ = registry.lock(); guards held here: {}",
registry.held.get()
);
}
{
let _guard = registry.lock();
println!(
" let _guard = registry.lock(); guards held here: {}",
registry.held.get()
);
}
println!(" Zero, then one. The first critical section was unprotected,");
println!(" and it is the spelling an `unused variable` warning suggests.");
println!(" With std's Mutex, rustc refuses the first line outright:");
println!(" error: non-binding let on a synchronization lock");
println!(" note: `#[deny(let_underscore_lock)]` on by default");
println!(" That deny is a special case for std's locks, not for the");
println!(" pattern — your own guard, above, drew no diagnostic at all.");
}
// ---------------------------------------------------------------- Part 3 ---
fn the_borrow_that_ended_too_late() {
banner("3. Three ways to end a borrow sooner, and none of them clone");
println!(" The starting point does not compile:");
println!(" let leader = &standings[0];");
println!(" standings.push(9); <- E0502, borrow still live");
println!(" println!(\"{{leader}}\"); <- because of THIS line");
// Fix A — move the last use up, so the borrow ends before the mutation.
let mut standings = vec![41_u32, 12];
let leader = &standings[0];
println!(" A) read first: leader = {leader}");
standings.push(9);
println!(" then mutate: {standings:?}");
// Fix B — give the borrow a block, so it cannot reach the mutation.
let mut standings = vec![41_u32, 12];
{
let leader = &standings[0];
println!(" B) borrow in a block: leader = {leader}");
}
standings.push(9);
println!(" mutate after it: {standings:?}");
// Fix C — take a copy, so there is no borrow to end.
let mut standings = vec![41_u32, 12];
let leader = standings[0]; // u32 is Copy: this is a read, not a borrow
standings.push(9);
println!(" C) copy the value: leader = {leader}, {standings:?}");
println!(" A is the one to ship: it is the same program with one line");
println!(" moved, and it says the read happens before the write.");
println!(" B is for when the borrow's users are a group, not a line.");
println!(" C changes the meaning — `leader` is now a snapshot, and on a");
println!(" non-Copy type its spelling is `.clone()`, with the cost that");
println!(" implies. Reach for it when you wanted a snapshot anyway.");
}
fn main() {
the_report_that_closed_too_early();
the_guard_released_one_character_early();
the_borrow_that_ended_too_late();
}
Verified output of scope_is_about_names_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
──── 1. Fields drop in DECLARATION order, not in reverse
broken — `_file` first, so the file closes before the write:
[broken] file closed
[broken] totals written
fixed — `_totals` first, and nothing else changed:
[fixed] totals written
[fixed] file closed
The reverse-order rule you learned for locals is not the rule
for fields. Two locals in the same order would have worked;
moving them into one struct silently flipped the sequence.
──── 2. `let _` is not a name, so it holds nothing
let _ = registry.lock(); guards held here: 0
let _guard = registry.lock(); guards held here: 1
Zero, then one. The first critical section was unprotected,
and it is the spelling an `unused variable` warning suggests.
With std's Mutex, rustc refuses the first line outright:
error: non-binding let on a synchronization lock
note: `#[deny(let_underscore_lock)]` on by default
That deny is a special case for std's locks, not for the
pattern — your own guard, above, drew no diagnostic at all.
──── 3. Three ways to end a borrow sooner, and none of them clone
The starting point does not compile:
let leader = &standings[0];
standings.push(9); <- E0502, borrow still live
println!("{leader}"); <- because of THIS line
A) read first: leader = 41
then mutate: [41, 12, 9]
B) borrow in a block: leader = 41
mutate after it: [41, 12, 9]
C) copy the value: leader = 41, [41, 12, 9]
A is the one to ship: it is the same program with one line
moved, and it says the read happens before the write.
B is for when the borrow's users are a group, not a line.
C changes the meaning — `leader` is now a snapshot, and on a
non-Copy type its spelling is `.clone()`, with the cost that
implies. Reach for it when you wanted a snapshot anyway.
Traps¶
- Reading "goes out of scope" as "is freed". It schedules the owner's drop. A value that was moved out is freed wherever its new owner ends, which may be another function entirely — see ownership and moves.
- Assuming a borrow lasts to the brace. It ends at its last use, so the block people add "to end the borrow early" is frequently doing nothing. Reach for it when several lines share the borrow, not reflexively.
- Assuming the reverse-order rule covers struct fields. It does not, and the compiler will not mention it. If a drop order matters, encode it in the field order and leave a comment saying so.
- Silencing
unused variableon a guard with a bare_. That is not a rename, it is a different statement, and it releases the thing you were holding._nameis the rename. - Expecting a
letto work like afn. Items are visible throughout the block; bindings start at their own line. Both are "in scope", and only one of them is available above.
See also¶
- A block is an expression — the other half of the brace: it opens the scope this page measures, and it is also a value
- A name is not a place — the sibling question: not when a name ends, but what it refers to, and why a shadow is not a write
- Borrowing — the rule whose region this page measures
- Ownership and moves — who owns the value whose scope end does the dropping
- A shadow does not drop — the value that outlives the name that hid it
- What a warning is asking —
_versus_name, as an answer to the compiler rather than as a timing decision - Lock poisoning — what a guard is protecting, and what happens when the thread holding it panics
- The Rust Reference — Destructors ↗, which is where the drop-order rules on this page are written down
Po polsku¶
Polskie „wyjść z zasięgu” (albo „z zakresu”) obsługuje w Ruscie trzy różne pytania, a odpowiedzi przychodzą w trzech różnych momentach:
- Do kiedy mogę użyć tej nazwy? — do zamykającego nawiasu klamrowego.
- Kiedy wartość zostaje zwolniona? — gdy jej właściciel wychodzi z zasięgu, co nie musi być w tym samym miejscu.
- Do kiedy pożyczenie ma znaczenie? — do ostatniego użycia referencji, zwykle znacznie wcześniej niż koniec bloku.
Nierozróżnianie tych trzech to źródło większości nieporozumień wokół kontrolera pożyczeń. Klasyczny objaw: ktoś twierdzi, że kod się nie skompiluje, „bo referencja żyje do końca funkcji” — a on się kompiluje, bo od NLL pożyczenie kończy się przy ostatnim użyciu.
Zasięg dotyczy nazw, nie wartości. Wartość przeniesiona gdzie indziej przeżyje koniec bloku, w którym powstała jej pierwsza nazwa; wartość, której nazwę przesłonięto, żyje dalej bez nazwy.
Drobiazg, który zaskakuje: let _ = value; natychmiast wypuszcza wartość, a let _name = value; trzyma ją do końca bloku. Samo _ nie jest nazwą, tylko wzorcem, który niczego nie wiąże — dlatego strażnik MutexGuard przypisany do _ odblokowuje muteks od razu, co jest jednym z klasycznych zakleszczeń „ale ja przecież przypisałem go do zmiennej”.
Szukaj po polsku: zasięg zmiennej Rust · wypuszczanie zasobów · rust NLL · rust MutexGuard let underscore deadlock