The Rust Engineering Handbook / Chapter 4
Expressions, Statements, Places, Values, and Evaluation
Trace Rust expressions precisely enough to predict values, effects, ownership movement, temporaries, coercions, and drop boundaries.
Do not execute this line in your head all at once
Read this posting expression once, then stop before deciding what it does:
let posted = ledgers[index_for(&request.account)?]
.post(parse_entry(request.payload())?)?;
Which happens first: parsing the entry or finding the ledger? Where can the function return? Can indexing panic? Which operand identifies storage, and which computes a value? How long must each borrow and temporary remain valid?
Answers based on how the line “looks” are unreliable. Rust’s evaluation rules are precise, but several different rules meet inside one expression. The index is found before the entry is parsed. Either operation can return from the enclosing function; indexing can panic; post adds a third return edge after its effect begins.
Those answers become predictable when you stop treating the line as a sentence. Start with its expression tree. Classify storage-denoting operands as places and computed results as values. Then apply the expression’s documented operand order, ownership mode, control-flow edges, coercion sites, and drop scopes. A dense line becomes a sequence of reviewable commitments.
Expressions produce values and may have effects
Rust is expression-oriented: blocks, if, match, loops, calls, assignments, and many control-flow forms are expressions. An expression produces a value and may also have effects. A statement controls evaluation within a block and normally ends with a semicolon; item declarations and let statements are important statement forms.
The slogan “everything is an expression” is memorable but imprecise. Items and let statements are not ordinary value-producing expressions, and some expression values are the unit type (). The useful model is that Rust allows value production deep inside control flow, so type, ownership, and effects must agree across branches.
Compare two blocks:
let cents = {
let dollars = 25_i64;
dollars * 100
};
let unit = {
let dollars = 25_i64;
dollars * 100;
};
The first block’s tail expression has no semicolon and the block evaluates to 2500_i64. The second evaluates the multiplication as an expression statement; the block has no tail expression and produces (). The semicolon does not merely decorate formatting. It changes which value flows out of the block.
This matters in if and match:
let route = if entry.cents() >= 0 {
Route::Credit
} else {
Route::Debit
};
Both branches must produce compatible types at the coercion site. The expression can be assigned because the branch result becomes the initializer’s value.
A place identifies storage; a value is a computed result
A place expression denotes a memory location. Local variables, static items, dereferences, indexing expressions, and field expressions are common place forms. A value expression denotes a value rather than a place.
The same syntax participates in different contexts:
let amount = entry.cents; // value context: read or move/copy from the place
let shared = &entry.cents; // place context: borrow the place
entry.cents = 500; // assignee place context
If cents is i64, the first line copies it because i64: Copy. If the field were String, a by-value read could move the string out when the surrounding type permits it. The place/value distinction tells you where the operation acts; the type and context determine whether the value is copied, moved, or borrowed.
Indexing is especially important:
let first = accounts[0].clone();
let first_ref = &accounts[0];
accounts[0] = "cash".to_owned();
accounts[0] identifies a place. The first line explicitly clones its value. The second borrows it. The third assigns a new value after evaluating the assigned value operand and the assignee according to assignment’s specific rule. Indexing can panic when bounds are violated; place classification does not remove that runtime edge.
A method call may borrow, mutate, or consume its receiver depending on the selected method’s self parameter. Reading ledger.summary() therefore requires the method signature. Surface dots do not tell you ownership. In the opening line, the indexed expression denotes a ledger place; the selected post signature determines whether that place is shared, mutably borrowed, or consumed.
The diagram is a compact trace aid. It deliberately shows two temporary bands with different endpoints: temporary lifetime is governed by syntactic scope rules, not by a universal “end of line” or “end of block” rule.
Operand order follows the expression, with named exceptions
For the expression families listed by the Rust Reference—including calls, method calls, indexing, arrays, tuples, structs, arithmetic operators, casts, field access, return, and the ? error-propagation expression—operands are evaluated before the expression’s main effect. Multiple operands are evaluated left to right as written, recursively from inner expressions outward.
That makes this trace stable:
receiver().method(first_argument(), second_argument())
The receiver operand is evaluated, then first_argument, then second_argument, then the method call effect occurs. Each nested operand completes according to its own rules before the next sibling.
Not every expression eagerly evaluates every written operand. && and || short-circuit. if evaluates one branch. match evaluates the selected arm. return, break, panic, and ? can leave surrounding evaluation before later operands are created.
Assignment has a documented order that deserves explicit attention: the assigned value on the right is evaluated before the assignee place on the left. Do not generalize left-to-right from calls to assignment.
For the opening expression, the trace is:
- Evaluate the receiver operand.
ledgersis a place. Borrowrequest.account, callindex_for, and apply the first?. On error, evaluation ends before any indexing or parsing. - Use the successful index to evaluate
ledgers[...], another place expression. A failed bounds check panics before the entry is parsed. - Evaluate the method argument. Borrow
requestforpayload, callparse_entry, and apply the second?. On error, the selected ledger has been located butposthas not run. - Apply the receiver adjustments established during method lookup, move or borrow the parsed entry according to the parameter type, and invoke
post. If the receiver is a trait object, selecting the concrete implementation is a separate dynamic-dispatch step. - Apply the outer
?. A post error returns after whatever effectspostperformed; Rust’s scope cleanup does not undo them. - Bind the success value to
posted.
The order is more than language trivia. It says exactly which work and effects precede each exit. If the index comes from untrusted or inconsistent state, get_mut returning Option may express the intended failure better than indexing. If parsing is expensive or mutates external state, its position relative to ledger lookup is an architectural choice rather than a formatting preference.
Pattern contexts decide what is bound or moved
The opening line ends in a simple identifier pattern, posted, but patterns can do much more work at the point a value arrives. They appear in let, function parameters, match, if let, let else, and loops. A pattern can destructure a value, test its shape, and bind fields by value or by reference.
let EntryView { account, cents } = view;
Whether fields copy, move, or borrow depends on the scrutinee, field types, binding modes, and explicit ref/reference patterns. Matching a place expression can avoid creating a temporary location; by-value bindings may still move or copy fields from that place.
This contrast is reviewable:
match maybe_entry {
Some(ref entry) => inspect(entry),
None => report_missing(),
}
match &maybe_entry {
Some(entry) => inspect(entry),
None => report_missing(),
}
Both can borrow the inner entry, but they establish that behavior through different pattern contexts. Match ergonomics can reduce explicit reference markers. That convenience should not replace the ownership question: is the scrutinee being consumed, borrowed, or matched as a place?
Catch-all patterns such as _ also have design consequences. They can preserve forward compatibility for a non-exhaustive external enum, but on an internal state machine they may hide a newly added state that needs explicit handling.
Coercions happen only at coercion sites
A coercion is an implicit type adjustment allowed at defined sites, usually where an expected type is known. Common sites include explicitly typed let bindings, function arguments, struct fields, return expressions, and some propagated subexpressions of arrays, tuples, and blocks.
fn label(value: &str) -> &str {
value
}
let owned = String::from("cash");
let borrowed: &str = &owned;
let returned = label(&owned);
The argument and typed binding are coercion sites where &String can become &str. This does not mean String and str are interchangeable or that arbitrary conversions happen anywhere inference needs help.
Method receivers undergo their own candidate search and adjustments: repeated dereferencing and possible borrowing are considered while the compiler looks for a visible applicable method. The precise algorithm matters when several traits or receiver forms compete, but the early reading rule is enough for Part I:
- identify the receiver’s actual type;
- identify the selected method and whether it takes
self,&self, or&mut self; - account for auto-borrow, auto-deref, and any unsizing adjustment;
- check which trait is in scope and whether an inherent method wins.
Do not explain a receiver adjustment as though the object physically changes representation unless the relevant coercion actually does so.
? is typed early return, not invisible exception flow
The error-propagation expression evaluates its operand. On success it yields the success value to the surrounding expression; on failure it returns a converted residual from the enclosing function or closure context that supports the operation.
In practical Result code:
let entry = parse_entry(payload)?;
ledger.post(entry)?;
The first ? means later statements are not evaluated after parse failure. Any initialized locals leaving scope are dropped according to normal control flow. The second means post failure exits before later success work. This is why ? placement is an operational design decision: effects before it have happened; effects after it have not.
For multi-step external work, early return can leave partial effects. Rust will clean up local values, but it cannot automatically roll back a remote write. Use transactional staging, idempotency, or a cleanup guard whose contract matches the effect.
Temporaries belong to syntactic drop scopes
Temporaries hold intermediate values needed during evaluation. Each has a temporary scope determined by Rust’s rules, with specific lifetime-extension cases. The ordinary shortcut “temporaries live to the semicolon” works often and fails precisely where advanced ownership reasoning matters.
Rust 2024 narrows some temporary scopes. A temporary created in a block’s tail expression is no longer extended beyond that block merely because it appears in the tail; it may therefore be dropped at the end of the block, before local variables declared there. if let temporary behavior also changed. These edition rules can make previously rejected code compile or previously accepted borrowing patterns require restructuring.
Local variables associated with a block are normally dropped in reverse order of declaration when leaving that scope. Temporaries in a scope are dropped in reverse creation order. Fields and collection elements have their own documented destruction order. An early return or ? leaves nested scopes from inside outward and drops initialized values associated with them.
The fixture’s trace example evaluates a receiver probe, then an argument probe, records the call, and observes argument then receiver destruction after the consuming method completes. That output is evidence for this exact program and toolchain. The documented operand and destructor rules are the guarantee; the print trace is a reproducible illustration.
Beware of temporary lifetime extension around borrowed literals and constructors:
let reference = &String::from("cash");
println!("{reference}");
Specific let initializer forms extend the temporary so the reference remains valid. Function-call arguments and method receivers are not generally extending expressions. Rely on the documented syntactic rules or introduce a named binding when clarity matters.
Alternatives that make evaluation clearer
The opening one-liner can be decomposed without pretending that the rewrite is semantically neutral:
let index = index_for(&request.account)?;
let ledger = ledgers.get_mut(index).ok_or(PostError::MissingLedger(index))?;
let entry = parse_entry(request.payload())?;
let posted = ledger.post(entry)?;
get_mut turns bounds failure into a typed branch, and named bindings make borrow extents easier to inspect. The rewrite also chooses to hold the mutable ledger borrow across parsing. That may create a borrow conflict if parsing needs access to data aliased through ledgers, and it lengthens the interval during which the ledger is exclusively borrowed. Parsing first is the alternative when that interval matters.
Two credible orders are:
| Order | Favors | Risk |
|---|---|---|
| Locate ledger, then parse | Fail quickly when destination is absent | Mutable ledger borrow may overlap parsing dependencies |
| Parse, then locate ledger | Keep mutable borrow short | Performs parsing work even when destination is absent |
The correct choice depends on failure frequency, parse cost, aliasing, and whether parsing has side effects. Precise evaluation turns the decision into an explicit trade-off.
Where an informal reading fails
Most mistaken traces collapse two different questions. “What does this syntax refer to?” becomes confused with “what happens to the referred-to value?” A field or index can denote a place while the surrounding context moves, copies, borrows, mutates, or replaces its contents. Dot syntax likewise says nothing by itself about whether a receiver is shared, mutably borrowed, or consumed.
The second collapse is between common order and universal order. Calls evaluate their operands left to right, but assignment evaluates the assigned value before the assignee, and conditional forms may leave written operands unevaluated. ? adds an exit; it does not add logging, retry, rollback, or exception catching. Indexing can panic before the method call. Temporaries follow syntactic drop scopes, not a general end-of-line rule.
When the source-level model answers the question, stop there. Illustrative desugaring is not exact compiler output, and one MIR dump or optimized assembly listing is evidence about a recorded compiler configuration, not a new language guarantee.
Senior review checklist
- What is the expression tree, including precedence and nested operands?
- Which operands are places, and in which contexts are their values moved, copied, borrowed, or assigned?
- Which operands are evaluated eagerly, conditionally, or not at all?
- Where can
?,return, panic, orbreakleave evaluation early? - Which effects have committed before each exit?
- Which implicit receiver adjustments and coercions occur at documented sites?
- What owns each temporary, and which edition-specific scope rule applies?
- Which initialized locals and temporaries drop on success and on every early exit?
- Would named bindings shorten borrows or make panic and cost edges clearer?
- Is any implementation inspection being mistaken for normative semantics?
Trace exercise: predict before executing
Take the compound posting expression from the opening. Write an ordered trace containing receiver evaluation, index calculation, each borrow, parse construction, every ? edge, indexing, receiver adjustment, the move or borrow into post, the successful result, and all drop boundaries. Method lookup and receiver-type selection belong in the compile-time account; dynamic dispatch, when present, remains a runtime event. Then rewrite the expression twice: once to minimize mutable-borrow duration and once to minimize wasted parsing work.
Run cargo run --example evaluation_trace only after recording your predicted output. If prediction and observation differ, identify whether your mistake concerned operand order, move into the method, or destruction order. Do not generalize the observed output beyond the documented rules and declared toolchain.
Durable takeaways
- Expressions produce values and effects; a block’s tail expression determines its outward value.
- Places identify storage, while contexts and types determine borrow, copy, move, mutation, or assignment.
- Most listed multi-operand expressions evaluate operands left to right, but conditional forms and assignment have their own rules.
?adds a typed early-return edge and ordinary scope cleanup, not transaction rollback.- Temporary and drop behavior follows explicit syntactic and edition-sensitive rules.
That trace model explains what a program asks Rust to accept. When the request is rejected, the diagnostic should be read as a relationship among these places, values, borrows, and uses—not as a list of edits to try.
Sources and version notes
- Verified with Rust 1.97.0, Edition 2024, on 2026-07-11. The evaluation trace is reproducible under the Part I fixture.
- The Rust Reference: expressions and operand order
- The Rust Reference: statements and expressions
- The Rust Reference: type coercions
- The Rust Reference: destructors and drop scopes
- Rust Edition Guide: 2024 tail-expression temporary scope
- Reproduction:
cargo run --example evaluation_trace.
Continue reading
Full table of contents