Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 108 additions & 54 deletions src/destructors.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,11 +375,8 @@ r[destructors.scope.lifetime-extension]
> [!NOTE]
> The exact rules for temporary lifetime extension are subject to change. This is describing the current behavior only.

r[destructors.scope.lifetime-extension.let]
The temporary scopes for expressions in `let` statements are sometimes
*extended* to the scope of the block containing the `let` statement. This is
done when the usual temporary scope would be too small, based on certain
syntactic rules. For example:
r[destructors.scope.lifetime-extension.intro]
The temporary scopes for expressions are sometimes *extended*. This is done when the usual temporary scope would be too small, based on certain syntactic rules. For example:

```rust
let x = &mut 0;
Expand All @@ -388,21 +385,27 @@ let x = &mut 0;
println!("{}", x);
```

r[destructors.scope.lifetime-extension.static]
Lifetime extension also applies to `static` and `const` items, where it
makes temporaries live until the end of the program. For example:
r[destructors.scope.lifetime-extension.sub-expressions]
If a [borrow], [dereference][dereference expression], [field][field expression], or [tuple indexing expression] has an extended temporary scope, then so does its operand. If an [indexing expression] has an extended temporary scope, then the indexed expression also has an extended temporary scope.
Comment on lines +388 to +389
Copy link
Contributor Author

@dianne dianne Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may need a terminology update, a carve-out in destructors.scope.lifetime-extension.exprs, or at least clarification on what "extended temporary scope" means. In particular, if you have an expression like

{ &*&temp() }

it's important that this rule takes precedence, so that temp() lives past the block. destructors.scope.lifetime-extension.exprs.borrows currently is worded to be the definitive lifetime of borrow operators' operands, but if only that was considered, temp() would be dropped at the end of the tail expression.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is partially addressed by the new example in 6536b3b. I think it'll still also want some reformulation or clarification of lifetime-extension.sub-expressions to make really make sense, though.

Copy link
Contributor

@traviscross traviscross Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't it almost feel as though the things in this rule should be rolled in to "extending based on expressions"? E.g.:

The extended scope of an expression is defined in terms of extending expressions and their extending parents. An extending expression is an expression which is one of the following... the indexed expression of a tuple indexing expression, the extending parent of which is the tuple indexing expression.

Or is there some important distinction we're trying to encode here in these being handled separately?

Copy link
Contributor Author

@dianne dianne Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a couple distinctions.

First, except for borrow expression operands, none of those subexpressions are extending. For instance,

let x = (&PrintOnDrop("temporary dropped")).0;
println!("next statement");

prints "temporary dropped", then "next statement". Intuitively, since the result of indexing is evaluated and then copied into x, the temporary doesn't need to be extended past its use, so we don't extend it. The purpose of lifetime-extension.sub-expressions is to handle reborrows, borrows of projections, etc.: if we'd instead written

let x = &(&PrintOnDrop("temporary dropped")).0;
println!("next statement");

we'd be reborrowing the temporary's field rather than evaluating it, so we would extend the temporary, so it'd print "next statement", then "temporary dropped". Extending subexpressions generally should only be those where if you substituted a borrow expression in, it's syntactically obvious that the borrow will need to be live to use the result of evaluating the extending parent expression. Maybe there's a way to express that intuition productively in an admonition somehow?

Second, lifetime-extension.sub-expressions also applies when extending based on patterns:

let ref x = PrintOnDrop("temporary dropped").0;
println!("next statement");

prints "next statement", then "temporary dropped". Because x is borrowing from a place based on the temporary, the entire temporary is extended. The example in lifetime-extension.sub-expressions illustrates this in a bit more detail.

Copy link
Contributor

@traviscross traviscross Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand how to apply this rule in the same way that rustc does. I put together a series of examples and mechanically analyzed them according to the rules in this PR. Can you explain the behavior of the last two examples in light of the behavior of the ones that precede them?

Playground link

Copy link
Contributor

@traviscross traviscross Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting that aside, and setting aside the matter of the interaction with "extending based on patterns" (which as we discussed on Zulip, might end up needing to be restructured eventually given how it would interact with a binding version of super let or super let in), it feels to me as though these are properly a kind of extending expression but that, as we work outward, we're carrying a flag indicating whether we last stepped through a "full" extending expression or through a more "limited" extending expression, and the value of that flag affects our behavior, e.g. when we get out to the let initializer.

(To the degree that's not true, maybe it's worth considering whether it should be.)

Copy link
Contributor Author

@dianne dianne Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My notes on each example, per the mental model I was aiming for with that change (I'll address the limited extending model after):

  1. let _ = (&e.log(1)).0; //~ Not extended.
    Our analyses agree here.
  2. let _ = &(&e.log(2)).0; //~ Extended.
    Our analyses agree here.
  3. let _ = &(&(e.log(2),).0).0; //~ Extended.
    e.log(2) here isn't extended; instead, it's immediately moved into the tuple temporary, which is extended. I believe its scope is that of the tuple expression, per destructors.scope.operands. I'd also add another step: the reason (&(e.log(2),).0).0 uses its extended scope as its temporary scope is because it's the operand of a borrow expression. Otherwise, our analyses agree.
  4. let _ = &[(e.log(2),).0][0].0; //~ Extended.
    In this case, the temporary that gets extended is the array given by evaluating [(e.log(2),).0]. A place based on it, array[0].0, is borrowed, and since we want that borrow to be live for the block, we extend the array's lifetime.
  5. let _ = &[(e.log(2),).0]; //~ Extended.
    As with 4, the temporary that gets extended is the array given by evaluating [(e.log(2),).0].
  6. let _ = &[(&e.log(1)).0]; //~ Not extended.
    As with 4 and 5, the temporary being extended is the array. The key here is that we're not borrowing from the e.log(1) temporary in the final value; we evaluate (&e.log(1)).0 to something like LogDrop(_e1, 1) where _e1 is a reborrow of e and then move that value into the array. As such, e.log(1) does not need to be extended; this is why tuple indexing expressions are not extending parents.
  7. let _ = &(&e.log(1),).0; //~ Not extended.
    This one we should extend according to the new model we're discussing. e.log(1) should have the tuple expression's temporary scope, which should be extended. Indeed, it's currently an error to use the result because it borrows from the dropped e.log(1) temporary. I don't think my compiler PR handles this yet either, but I'd like for it to work.
    The way rustc does this is when it extends (&e.log(1),).0 it applies lifetime-extension.sub-expressions to extend the place(s) it's based on as well (so (&e.log(1),)) and then stops.

For the limited extending model, I think there's a conceptual reason to keep them distinct. Extending parent expressions represent things like constructors and conditionals that build (or select) a value and are guaranteed to contain (or evaluate to) their extending subexpressions. Projections like .0 are sort of a dual to that: if we want to extend the lifetime of a projected place, we have to extend the base place too, thus we extend the indexed operand. Actually, I think under this PR's model, we can clean it up and get & out of the "sub-expressions" list so it's just the base places of place expressions. I'll experiment with that on the compiler side of things and update this PR if it works out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what the right thing to do is in cases like

let x = [&temp(), local][1];
let y = &[&temp(), local][1];

I see a few options:

  1. Under rustc's current model (including under Temporary lifetime extension for blocks rust#146098 currently), neither let statement extends its temp(). This is safest in a way: this means we're not extending unused temporaries to the end of the block. Of course, it's still possible today to create unused extended temporaries, e.g. with let _ = &temp();.
  2. Under this PR after the latest update, I think only the definition of y would extend its temp(), since the array would have an extended temporary scope and temp() would have the array's temporary scope as its temporary scope. In the case of x, the array wouldn't have an extended temporary scope because the result of indexing is used as a value.
  3. Under a model where indexed expressions are limited-extending, I could possibly see both statements extending their temp()s, because in either case we can see that the temp() could end up referenced in the final value. I'd have to think through what the exact rules would be to end up with that result.

Copy link
Contributor

@traviscross traviscross Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Regarding 3..=6... oops, yes, of course -- I must have been staring at too many variations for too long. Interesting about 7.

Revised analysis of the interesting cases:

Playground link

Regarding the sub-expressions rule:

If a [borrow], [dereference][dereference expression], [field][field expression], or [tuple indexing expression] has an extended temporary scope, then so does its operand. If an [indexing expression] has an extended temporary scope, then the indexed expression also has an extended temporary scope.

In "extending based on expressions", we're defining expressions to have an extended scope even if that extended scope is never used. E.g.:

r[destructors.scope.lifetime-extension.exprs.let]
The extended scope of the initializer expression of a let statement is the scope of the block containing the let statement.

For sub-expressions, then, it seems we have to account for that somehow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In "extending based on expressions", we're defining expressions to have an extended scope even if that extended scope is never used.

For sub-expressions, then, it seems we have to account for that somehow.

This is a terminology problem. I couldn't think of a better term than "extended scope" at the time, but there's meant to be a distinction. Every expression has an extended scope. Borrow expressions' operands use their extended scope as their temporary scope; as such, they're said to have an extended temporary scope (though it looks like I forgot to add that definition). The initializer expression of a let statement with an extending pattern also has an extended temporary scope.

I can think of a few things that might help:

  • Actually defining "extended temporary scope". That term is a holdover from the stable version of the temporary lifetime extension section, where it was more obvious what it meant. It might still be obvious enough here if we can get rid of the other too-similar terms, and under the definition I proposed above it might be confusing that the "extended temporary scope" of an expression can sometimes be its normal temporary scope.
  • Removing the term "extended temporary scope". sub-expressions is e.g. saying that the tuple operand expression of a tuple indexing expression has the same temporary scope as the tuple indexing expression. We don't need a notion of "extended temporary scope" to express that.
  • Renaming the term "extended scope". The term I used in the compiler is "extended_parent scope" to match with the notions of "parent scope" and "var_parent scope" used there. That feels too close to "extending parent" though, maybe something like "extended ancestor scope" would be fine? I'm not sure.
  • Removing the term "extended scope". e.g. a previous iteration of this PR defined the "extending ancestor" of an expression as the closest non-extending expression above that expression, then defined the scopes of borrow expressions' operands and super temporaries in terms of its temporary scope. It felt messier, so I ended making rules to determine its temporary scope directly (as the "extended scope" of an expression).

With this in mind, there isn't intended to be any ambiguity or a need to determine precedence between exprs and sub-expressions. The rules should be written in a way that makes everything clear. They just haven't quite gotten there yet.


```rust
const C: &Vec<i32> = &Vec::new();
// Usually this would be a dangling reference as the `Vec` would only
// exist inside the initializer expression of `C`, but instead the
// borrow gets lifetime-extended so it effectively has `'static` lifetime.
println!("{:?}", C);
# use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
# static X: AtomicU64 = AtomicU64::new(0);
# struct PrintOnDrop(&'static str);
# impl Drop for PrintOnDrop {
# fn drop(&mut self) {
# X.fetch_add(1, Relaxed);
# println!("{}", self.0);
# }
# }
let x = &(0, PrintOnDrop("tuple 1 dropped")).0;
let ref y = (0, PrintOnDrop("tuple 2 dropped")).0;
// Though only its first field is borrowed, the temporary for the entire tuple
// lives to the end of the block in both cases.
println!("{x}, {y}");
# assert_eq!(0, X.load(Relaxed));
```

r[destructors.scope.lifetime-extension.sub-expressions]
If a [borrow], [dereference][dereference expression], [field][field expression], or [tuple indexing expression] has an extended temporary scope, then so does its operand. If an [indexing expression] has an extended temporary scope, then the indexed expression also has an extended temporary scope.

r[destructors.scope.lifetime-extension.patterns]
#### Extending based on patterns

Expand Down Expand Up @@ -445,7 +448,7 @@ So `ref x`, `V(ref x)` and `[ref x, y]` are all extending patterns, but `x`, `&r

r[destructors.scope.lifetime-extension.patterns.let]
If the pattern in a `let` statement is an extending pattern then the temporary
scope of the initializer expression is extended.
scope of the initializer expression is extended to the scope of the block containing the `let` statement.

```rust
# fn temp() {}
Expand Down Expand Up @@ -473,37 +476,102 @@ let &ref x = &*&temp(); // OK
r[destructors.scope.lifetime-extension.exprs]
#### Extending based on expressions

r[destructors.scope.lifetime-extension.exprs.borrows]
The [temporary scope] of the operand of a [borrow] expression is the *extended scope* of the operand expression, defined below.

r[destructors.scope.lifetime-extension.exprs.super-macros]
The [scope][temporary scope] of each [super temporary] of a [super macro call] expression is the extended scope of the super macro call expression.
Comment on lines 476 to +483
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here:

  • I think it's helpful to start destructors.scope.lifetime-extension.exprs with a straightforward explanation of what all the definitions are for. Otherwise, I found that either too many definitions are needed before any of them can be explained, or the explanations have to refer to terms that haven't been defined yet. For the sake of directness, I moved destructors.scope.lifetime-extension.exprs.borrows to the start, but that meant having to refer to terms that are defined later. A possible alternative would be writing a new destructors.scope.lifetime-extension.exprs.intro rule, but when I tried that it felt redundant.
  • I don't love the term "extended scope", but I wasn't able to think of anything better, either in terms of naming or in terms of reframing to avoid defining it at all. I feel like it could probably also use some additional clarification but I'm still thinking of a good admonition to add for it.


r[destructors.scope.lifetime-extension.exprs.extending]
For a let statement with an initializer, an *extending expression* is an
expression which is one of the following:
The extended scope of an expression is defined in terms of *extending expressions* and their *extending parents*. An extending expression is an expression which is one of the following:
Comment on lines 485 to +486
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things here:

  • To simplify the definitions of extending expressions and extended scopes, I've made let initializers, static/const items, and const block tails no longer extending. This might hurt the intuition of extending expressions a bit though.
  • I added the definition of "extending parent" when reworking the upwards walk. This keeps it rigorous without hacks (the parent of an expression isn't defined elsewhere, and going through the parent of a scope to save words is confusing). It also also makes it clear that extending expressions are all subexpressions and thus have parent expressions. But spending words on it feels a bit awkward too, either adding bloat or making things convoluted (or both).
  • Extending expressions are necessary to define extended scopes, but extended scopes are necessary to justify the definition of extending expressions. As such, I felt a need to connect this to the surrounding sections, but I don't love how it turned out. Maybe there's a better way to handle that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on making "extending parents" make more sense and making it clear that "extending expression" isn't a classification of expressions but a classification of subexpressions, maybe it would be worth renaming "extending expression" to "extending subexpression"? I think it makes more sense from a spec point of view, but it's more awkward to read and write (and it means having to adapt language elsewhere), so I haven't gone through with it.

Copy link
Contributor

@traviscross traviscross Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has some appeal, but I similarly see the problems you mention. If one says "extending subexpression", then one kind of wants the parent to be an "extending expression" (in the same way as "header", "subheader", etc.), but that's probably confusing.


* The initializer expression.
* The operand of an extending [borrow] expression.
* The [super operands] of an extending [super macro call] expression.
* The operand(s) of an extending [array][array expression], [cast][cast
* The operand of a [borrow] expression, the extending parent of which is the borrow expression.
* The [super operands] of a [super macro call] expression, the extending parent of which is the macro call expression.
* The operand(s) of an [array][array expression], [cast][cast
expression], [braced struct][struct expression], or [tuple][tuple expression]
expression.
* The arguments to an extending [tuple struct] or [tuple enum variant] constructor expression.
* The final expression of an extending [block expression] except for an [async block expression].
* The final expression of an extending [`if`] expression's consequent, `else if`, or `else` block.
* An arm expression of an extending [`match`] expression.
expression, the extending parent of which is the array, cast, braced struct, or tuple expression.
* The arguments to a [tuple struct] or [tuple enum variant] constructor expression, the extending parent of which is the constructor expression.
* The final expression of a plain [block expression] or [`unsafe` block expression], the extending parent of which is the block expression.
* The final expression of an [`if`] expression's consequent, `else if`, or `else` block, the extending parent of which is the `if` expression.
* An arm expression of a [`match`] expression, the extending parent of which is the `match` expression.
Comment on lines +488 to +496
Copy link
Contributor

@traviscross traviscross Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's maybe not what we want to do right now, but it occurred to me that these and the other rules could probably be usefully presented in the manner of inference rules, e.g. (and making no assertions about correctness of this sketch):

enum Scope {
    Block,
    Stmt,
    // ...
}
struct S(scope: Scope, ext_scope: Scope);

/// Borrow expressions
S(x, x) ⊢  $expr
----------------
S(s, x)&$expr

/// Array expressions
S(s, x) ⊢  $expr
----------------------
S(s, x)[$expr, ...]

/// Function calls
S(s, s) ⊢       $expr
---------------------------
S(s, x) ⊢ $path($expr, ...)

/// Let initializers
S(Stmt, Block)&$expr
---------------------------------
S(Stmt, Block)let ... = &$expr

// Etc.

When @Nadrieril and I were working on match ergonomics, we ended up finding it rather helpful for analysis to present the rules in this manner. See, e.g. this document for a discussion of notation.

@Nadrieril ended up writing a cool web tool for doing analysis and comparisons based on this kind of notation.

It's similarly been occurring to me here that it would be helpful to have a tool that encodes the formal rules in software and walks through the analysis of an expression. (Of course, one option is always to do something like that in the compiler with an approach similar to #[rustc_capture_analysis]).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty familiar with inference rules; if it would help to discuss them in that way, I could write them out for different variants of lifetime extension (e.g. the stable version, the compiler PR version where exprs.other uses the enclosing scope of the extended parent, the version we've been talking about where exprs.other uses the temporary scope of the extended parent, etc.).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would definitely be helpful. I could see including that directly in the Reference as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a bit of trouble coming up with good notation for this. When formalizing the relation "the temporary scope of $expr is $scope", $expr isn't really an expression, but the context in which the expression appears. Scopes, likewise, are contexts. This isn't a problem for the formalism, but it's more difficult for human-readability and understanding when the relation is between context stacks (or worse, zippers; or between a stack and an index into it; etc.). Any ideas on what would be most understandable for sake of communicating these rules?

If there's a good way to talk about contexts, maybe it could be helpful for clarifying the Reference text as well? The scoping rules are all about expressions' contexts, but in their text we tend not to distinguish between expressions and their contexts. E.g. when we say that the tail expression of a block is an extending expression, "the tail expression of a block" is a context, but we refer to it through the expression that goes there.

And regarding tools that walk through the analysis of an expression, it's not exactly purpose-built, but would it be helpful enough to have the formalism in an interactive proof assistant like Rocq or Lean? You could set a goal like "there exists a scope such that the temporary scope of $ctxt is scope" and use a tactic to apply the next applicable rule at each step. That's probably easier to make than a website (though less convenient to use for comparing the rulesets).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding contexts and how to formalize it, it's a good question and distinction. Will think about it. No immediate thoughts (other than the simplistic sketch above that approaches it syntactically and tracks the current category of the temporary scope and extended scope). Probably it'd help to see sketches of some of the things you've considered or tried.

Regarding tooling, using Rocq or Lean could be interesting if we want to prove general properties about the rules, e.g. the assertion about the consistency of the relative drop order. What might other interesting general properties to prove be?

For just applying the rules step-by-step to particular examples, perhaps a more a-mir-formality-style approach would also work. I.e., perhaps it could be modeled in Rust, but the UI doesn't need to be fancy.

Copy link
Contributor

@traviscross traviscross Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key bit, if we could find it, is just having some more regular and concise way to reason about it. That the notation is a tool for clear thinking for us is more important than having any tooling.

Even such notation, though, is a nice-to-have. I don't want to distract us too much from working out good text here. If we can find good notation that's helpful for understanding, communication, and working out clear text, then great. If not, we'll iterate on the text and come up with something clear, and that'll be great too.

@RalfJung, do you perhaps have any ideas about how to express this more formally? We're essentially talking about how to write, operationally, how the temporary scope is determined for the context of some expression.

@nikomatsakis, similar question, based on your work with a-mir-formality.

The relevant Reference sections are:

Copy link
Member

@RalfJung RalfJung Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love that section to be written more formally, I currently find it very hard to understand.^^

In terms of how to express it, I am not sure I have much to add to what @dianne said. I would represent contexts as "expressions with a hole"; in Rocq this is a lot of work as you need to define an entirely new type, but somewhat informally we can just use the already existing expressions and put a hole somewhere. @dianne, you said this would require inventing more notation than an explicit stack -- why that? Other than picking a symbol for the hole, what notation do you need? I am not sure about using _ for the hole since that is also often used for "does not matter", so I will use ● which is also the symbol we use in papers. My rather naive first impression is that the main thing we have to define is the grammar of permitted contexts -- that doesn't even need inference rules, it just needs to say which "expressions with a hole" we can put around an &_ and have it be lifetime extended to the outside of that context:

lifetime-extension-ctx ::= ● | (..., lifetime-extension-ctx, ...) | { ...; lifetime-extension-ctx }

This grammar then makes it so that (5, ●, g(16)) is a valid lifetime extension context, but f(●, 33) is not. I know there's more to lifetime extension than this context, in particular when considering ref patterns, but it seems to me that this would be the core of it. Or maybe I am just way too naive about how complicated this really is.

This is not operational at all, but I don't think of lifetime extension as something operational, so this is par for the course I would say. (The operational version would be... writing out the actual algorithm that computes when exactly which temporary gets dropped? I can't imagine that being nice or simple. Just because I think the runtime semantics of the program should be expressed operationally doesn't mean I think everything should be expressed operationally. :)

I'm not entirely sure what a "scope" would be formalized as, I just don't know lifetime extension well enough.

Copy link
Contributor Author

@dianne dianne Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dianne, you said this would require inventing more notation than an explicit stack -- why that? Other than picking a symbol for the hole, what notation do you need?

The remaining bit of notation I was thinking of is for concatenation/substitution/composition of contexts, in order to talk about a local context (which we examine) within a larger context (which we assign a name). e.g., for determining the temporary scope of a borrow expression's operand, the object we're determining the temporary scope of is &●-within-some-larger-context. I assume this is a standard operation (especially as concerns going from "an expression with a hole" to "the expression with the hole filled in"), so it's likely there's some standard notation I'm not aware of. My first guesses would be some sort of brackets to evoke application, ∘ for composition, or ⧺ for concatenation.

My rather naive first impression is that the main thing we have to define is the grammar of permitted contexts -- that doesn't even need inference rules, it just needs to say which "expressions with a hole" we can put around an &_ and have it be lifetime extended to the outside of that context:

Defining a grammar of permitted contexts works, but the trick is that the scope of lifetime-extended temporaries depends on the context in which the lifetime extension context appears, so I'd like a way to capture that formally as well (hence reaching for composition). For stable Rust, we can be somewhat ad-hoc about this: if the extending context is the initializer of a let statement, we extend to the block the let statement is in; if the extending context is a const/static body, we extend to outside of the program's runtime. For the new lifetime extension semantics I'm proposing though, under this framing, I'd like for a lifetime extension context to be able to start in any non-lifetime-extension context, in which case it extends temporaries to the temporary scope enclosing the non-lifetime-extension context. This is so, e.g., the popular patterns

print!("{}", if cond() { &format!(...) } else { "..." });

and

print!("{}", if cond() { format_args!(...) } else { format_args!(...) });

work. Currently, since the &format!(...) and format_args!(...) are not in extending contexts, their temporaries are dropped within the if expression's block; they would however be extended if the if expression was directly made the initializer of a let statement. Under my proposed semantics, the &format!(...) and format_args!(...) would be in extending contexts that end at the argument to print!, so they'd be dropped in the temporary scope enclosing that context: the statement (a yet larger context).

Another way to view my proposal, which reflects the intuition I'd propose to capture in the Reference, is that temporary scope of the operand of a borrow operator is always determined by lifetime extension rules: you remove the longest extending suffix from its context before determining its temporary scope. Under this view, formally, I'm imagining lifetime extension would just be part of the definition of "the temporary scope of a context", though for pedagogical reasons they should maybe still be presented separately in the Reference.

I'm not entirely sure what a "scope" would be formalized as, I just don't know lifetime extension well enough.

I'd say it could be viewed as a syntactic context as well, but with some semantics bolted on (when execution leaves the context, values associated with it are dropped). For the most part scopes do correspond to expressions' contexts, but you'd probably need more notation, e.g. to represent scopes that don't correspond directly to single AST objects, such as "The pattern-matching condition(s) and consequent body of if"; that one can't clearly be written as an expression with a hole, even though formally it's not that different.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remaining bit of notation I was thinking of is for concatenation/substitution/composition of contexts, in order to talk about a local context (which we examine) within a larger context (which we assign a name). e.g., for determining the temporary scope of a borrow expression's operand, the object we're determining the temporary scope of is &●-within-some-larger-context. I assume this is a standard operation (especially as concerns going from "an expression with a hole" to "the expression with the hole filled in"), so it's likely there's some standard notation I'm not aware of. My first guesses would be some sort of brackets to evoke application, ∘ for composition, or ⧺ for concatenation.

The standard notation is: if K is a context and e an expression, then K[e] is "fill e into the hole of K", and if K1 and K2 are both contexts then K1[K2] is what you call "concatenation", i.e. fill K2 into the hole of K1. That's what I would use, anyway; K1 ∘ K2 and K1 ⧺ K2 would also be perfectly understandable.

That said, this may clash with Rust's use of [ for its own syntax, so I like the you used above.

I'd say it could be viewed as a syntactic context as well, but with some semantics bolted on (when execution leaves the context, values associated with it are dropped). For the most part scopes do correspond to expressions' contexts, but you'd probably need more notation, e.g. to represent scopes that don't correspond directly to single AST objects, such as "The pattern-matching condition(s) and consequent body of if"; that one can't clearly be written as an expression with a hole, even though formally it's not that different.

Nice, I like that.

For stable Rust, we can be somewhat ad-hoc about this: if the extending context is the initializer of a let statement, we extend to the block the let statement is in; if the extending context is a const/static body, we extend to outside of the program's runtime. For the new lifetime extension semantics I'm proposing though, under this framing, I'd like for a lifetime extension context to be able to start in any non-lifetime-extension context, in which case it extends temporaries to the temporary scope enclosing the non-lifetime-extension context.

Oh I see. I can't immediately grasp the full set of consequences from this, but it does sound like a neat idea.

Currently, since the &format!(...) and format_args!(...) are not in extending contexts, their temporaries are dropped within the if expression's block; they would however be extended if the if expression was directly made the initializer of a let statement. Under my proposed semantics, the &format!(...) and format_args!(...) would be in extending contexts that end at the argument to print!, so they'd be dropped in the temporary scope enclosing that context: the statement (a yet larger context).

Let me try to rephrase to make sure I understand: print!("{}", &foo()) would work (with the result of foo() being dropped at the end of the surrounding statement), and hence you suggest that print!("{}", if _ { &foo() } else { "bar" }) should also work, since the stuff that's "in between" the new and old location of the & already permits lifetime extension and therefore should not affect the lifetime of &foo()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me try to rephrase to make sure I understand: print!("{}", &foo()) would work (with the result of foo() being dropped at the end of the surrounding statement), and hence you suggest that print!("{}", if _ { &foo() } else { "bar" }) should also work, since the stuff that's "in between" the new and old location of the & already permits lifetime extension and therefore should not affect the lifetime of &foo()?

Exactly, yeah.


> [!NOTE]
> The desugaring of a [destructuring assignment] makes its assigned value operand (the RHS) an extending expression within a newly-introduced block. For details, see [expr.assign.destructure.tmp-ext].

So the borrow expressions in `&mut 0`, `(&1, &mut 2)`, and `Some(&mut 3)`
> [!NOTE]
> `rustc` does not treat [array repeat operands] of [array] expressions as extending expressions. Whether it should is an open question.
>
> For details, see [Rust issue #146092](https://github.com/rust-lang/rust/issues/146092).

So the borrow expressions in `{ &mut 0 }`, `(&1, &mut 2)`, and `Some(&mut 3)`
are all extending expressions. The borrows in `&0 + &1` and `f(&mut 0)` are not.

r[destructors.scope.lifetime-extension.exprs.borrows]
The operand of an extending [borrow] expression has its [temporary scope] [extended].
r[destructors.scope.lifetime-extension.exprs.parent]
The extended scope of an extending expression is the extended scope of its extending parent.

r[destructors.scope.lifetime-extension.exprs.super-macros]
The [super temporaries] of an extending [super macro call] expression have their [scopes][temporary scopes] [extended].
r[destructors.scope.lifetime-extension.exprs.let]
The extended scope of the initializer expression of a `let` statement is the scope of the block containing the `let` statement.

> [!EXAMPLE]
> In this example, the temporary value holding the result of `temp()` is extended to the end of the block in which `x` is declared:
>
> ```rust,edition2024
> # fn temp() {}
> let x = { &temp() };
> println!("{x:?}");
> ```
>
> `temp()` is the operand of a borrow expression, so its temporary scope is its extended scope.
> To determine its extended scope, look outward:
>
> * Since borrow expressions' operands are extending, the extended scope of `temp()` is the extended scope of its extending parent, the borrow expression.
> * `&temp()` is the final expression of a plain block. Since the final expressions of plain blocks are extending, the extended temporary scope of `&temp()` is the extended scope of its extending parent, the block expression.
> * `{ &temp() }` is the initializer expression of a `let` statement, so its extended scope is the scope of the block containg that `let` statement.
>
> If not for temporary lifetime extension, the result of `temp()` would be dropped after evaluating the tail expression of the block `{ &temp() }` ([destructors.scope.temporary.enclosing]).

r[destructors.scope.lifetime-extension.exprs.static]
The extended scope of the body expression of a [static][static item] or [constant item], and of the final expression of a [const block expression], is the entire program. This prevents destructors from being run.

```rust
# #[derive(Debug)] struct PanicOnDrop;
# impl Drop for PanicOnDrop { fn drop(&mut self) { panic!() } }
# impl PanicOnDrop { const fn new() -> PanicOnDrop { PanicOnDrop } }
const C: &PanicOnDrop = &PanicOnDrop::new();
// Usually this would be a dangling reference as the result of
// `PanicOnDrop::new()` would only exist inside the initializer expression of
// `C`, but instead the borrow gets lifetime-extended so it effectively has
// a `'static` lifetime and its destructor is never run.
println!("{:?}", C);
// `const` blocks may likewise extend temporaries to the end of the program:
// the result of `PanicOnDrop::new()` is not dropped.
println!("{:?}", const { &PanicOnDrop::new() });
```

r[destructors.scope.lifetime-extension.exprs.other]
The extended scope of any other expression is its [temporary scope].

> [!NOTE]
> `rustc` does not treat [array repeat operands] of extending [array] expressions as extending expressions. Whether it should is an open question.
> In this case, the expression is not extending, meaning it cannot be a borrow expression or a [super operand][super operands] to a [super macro call] expression, so its temporary scope is given by [destructors.scope.temporary.enclosing].

> [!EXAMPLE]
> In this example, the temporary value holding the result of `temp()` is extended to the end of the statement:
>
> For details, see [Rust issue #146092](https://github.com/rust-lang/rust/issues/146092).
> ```rust,edition2024
> # fn temp() {}
> # fn use_temp(_: &()) {}
> use_temp({ &temp() });
> ```
>
> `temp()` is the operand of a borrow expression, so its temporary scope is its extended scope.
> To determine its extended scope, look outward:
>
> * Since borrow expressions' operands are extending, the extended scope of `temp()` is the extended scope of its extending parent, the borrow expression.
> * `&temp()` is the final expression of a plain block. Since the final expressions of plain blocks are extending, the extended scope of `&temp()` is the extended scope of its extending parent, the block expression.
> * `{ &temp() }` is the argument of a call expression, which is not extending. Since no other cases apply, its extended scope is its temporary scope.
> * Per [destructors.scope.temporary.enclosing], the temporary scope of `{ &temp() }`, and thus the extended scope of `temp()`, is the scope of the statement.
>
> If not for temporary lifetime extension, the result of `temp()` would be dropped after evaluating the tail expression of the block `{ &temp() }` ([destructors.scope.temporary.enclosing]).

#### Examples

Expand Down Expand Up @@ -606,22 +674,6 @@ let x = 'a: { break 'a &temp() }; // ERROR
# x;
```

```rust,edition2024,compile_fail,E0716
# use core::pin::pin;
# fn temp() {}
// The argument to `pin!` is only an extending expression if the call
// is an extending expression. Since it's not, the inner block is not
// an extending expression, so the temporaries in its trailing
// expression are dropped immediately.
pin!({ &temp() }); // ERROR
```

```rust,edition2024,compile_fail,E0716
# fn temp() {}
// As above.
format_args!("{:?}", { &temp() }); // ERROR
```

r[destructors.forget]
## Not running destructors

Expand All @@ -647,6 +699,7 @@ There is one additional case to be aware of: when a panic reaches a [non-unwindi
[Assignment]: expressions/operator-expr.md#assignment-expressions
[binding modes]: patterns.md#binding-modes
[closure]: types/closure.md
[constant item]: items/constant-items.md
[destructors]: destructors.md
[destructuring assignment]: expr.assign.destructure
[expression]: expressions.md
Expand All @@ -660,6 +713,7 @@ There is one additional case to be aware of: when a panic reaches a [non-unwindi
[promoted]: destructors.md#constant-promotion
[scrutinee]: glossary.md#scrutinee
[statement]: statements.md
[static item]: items/static-items.md
[temporary]: expressions.md#temporaries
[unwinding]: panic.md#unwinding
[variable]: variables.md
Expand All @@ -681,22 +735,22 @@ There is one additional case to be aware of: when a panic reaches a [non-unwindi

[array expression]: expressions/array-expr.md#array-expressions
[array repeat operands]: expr.array.repeat-operand
[async block expression]: expr.block.async
[block expression]: expressions/block-expr.md
[borrow]: expr.operator.borrow
[cast expression]: expressions/operator-expr.md#type-cast-expressions
[const block expression]: expr.block.const
[dereference expression]: expressions/operator-expr.md#the-dereference-operator
[extended]: destructors.scope.lifetime-extension
[field expression]: expressions/field-expr.md
[indexing expression]: expressions/array-expr.md#array-and-slice-indexing-expressions
[struct expression]: expressions/struct-expr.md
[super macro call]: expr.super-macros
[super operands]: expr.super-macros
[super temporaries]: expr.super-macros
[super temporary]: expr.super-macros
[temporary scope]: destructors.scope.temporary
[temporary scopes]: destructors.scope.temporary
[tuple expression]: expressions/tuple-expr.md#tuple-expressions
[tuple indexing expression]: expressions/tuple-expr.md#tuple-indexing-expressions
[`unsafe` block expression]: expr.block.unsafe

[`for`]: expressions/loop-expr.md#iterator-loops
[`if let`]: expressions/if-expr.md#if-let-patterns
Expand Down
12 changes: 6 additions & 6 deletions src/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ r[expr.super-macros.intro]
Certain built-in macros may create [temporaries] whose [scopes][temporary scopes] may be [extended]. These temporaries are *super temporaries* and these macros are *super macros*. [Invocations][macro invocations] of these macros are *super macro call expressions*. Arguments to these macros may be *super operands*.

> [!NOTE]
> When a super macro call expression is an [extending expression], its super operands are [extending expressions] and the [scopes][temporary scopes] of the super temporaries are [extended]. See [destructors.scope.lifetime-extension.exprs].
> The super operands of a super macro call are [extending expressions] and the [scopes][temporary scopes] of the super temporaries are [extended]. See [destructors.scope.lifetime-extension.exprs].

r[expr.super-macros.format_args]
#### `format_args!`
Expand All @@ -272,10 +272,11 @@ Except for the format string argument, all arguments passed to [`format_args!`]

```rust,edition2024
# fn temp() -> String { String::from("") }
// Due to the call being an extending expression and the argument
// being a super operand, the inner block is an extending expression,
// so the scope of the temporary created in its trailing expression
// is extended.
// Due to the argument being a super operand, the inner block is an
// extending expression, so the scope of the temporary created in its
// trailing expression is extended to the extended scope of the call.
// Since the call is the initializer of a `let` statement, this
// extends it to the end of the surrounding block.
let _ = format_args!("{}", { &temp() }); // OK
```

Expand Down Expand Up @@ -406,7 +407,6 @@ They are never allowed before:
[destructors]: destructors.md
[drop scope]: destructors.md#drop-scopes
[extended]: destructors.scope.lifetime-extension
[extending expression]: destructors.scope.lifetime-extension.exprs
[extending expressions]: destructors.scope.lifetime-extension.exprs
[field]: expressions/field-expr.md
[functional update]: expressions/struct-expr.md#functional-update-syntax
Expand Down
6 changes: 3 additions & 3 deletions src/expressions/operator-expr.md
Original file line number Diff line number Diff line change
Expand Up @@ -900,9 +900,9 @@ r[expr.assign.destructure.tmp-scopes]

r[expr.assign.destructure.tmp-ext]
> [!NOTE]
> Due to the desugaring, the assigned value operand (the RHS) of a destructuring assignment is an [extending expression] within a newly-introduced block.
> Due to the desugaring, the assigned value operand (the RHS) of a destructuring assignment is the initializer expression of a `let` statement within a newly-introduced block.
>
> Below, because the [temporary scope] is extended to the end of this introduced block, the assignment is allowed.
> Below, because the [temporary scope] is [extended] to the end of this introduced block, the assignment is allowed.
>
> ```rust
> # fn temp() {}
Expand Down Expand Up @@ -1089,7 +1089,7 @@ As with normal assignment expressions, compound assignment expressions always pr
[dropping]: ../destructors.md
[eval order test]: https://github.com/rust-lang/rust/blob/1.58.0/src/test/ui/expr/compound-assignment/eval-order.rs
[explicit discriminants]: ../items/enumerations.md#explicit-discriminants
[extending expression]: destructors.scope.lifetime-extension.exprs
[extended]: destructors.scope.lifetime-extension.exprs
[field-less enums]: ../items/enumerations.md#field-less-enum
[grouped expression]: grouped-expr.md
[literal expression]: literal-expr.md#integer-literal-expressions
Expand Down
4 changes: 2 additions & 2 deletions src/items/constant-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const _: &mut u8 = unsafe { &mut S }; // ERROR.
> // the program.
> ```
>
> Here, the value `0` is a temporary whose scope is extended to the end of the program (see [destructors.scope.lifetime-extension.static]). Such temporaries cannot be mutably borrowed in constant expressions (see [const-eval.const-expr.borrows]).
> Here, the value `0` is a temporary whose scope is extended to the end of the program (see [destructors.scope.lifetime-extension.exprs.static]). Such temporaries cannot be mutably borrowed in constant expressions (see [const-eval.const-expr.borrows]).
>
> To allow this, we'd have to decide whether each use of the constant creates a new `u8` value or whether each use shares the same lifetime-extended temporary. The latter choice, though closer to how `rustc` thinks about this today, would break the conceptual model that, in most cases, the constant initializer can be thought of as being inlined wherever the constant is used. Since we haven't decided, and due to the other problem mentioned, this is not allowed.

Expand Down Expand Up @@ -175,7 +175,7 @@ const _: &&mut u8 = unsafe { &S }; // OK.
> const _: &AtomicU8 = &AtomicU8::new(0); // ERROR.
> ```
>
> Here, the `AtomicU8` is a temporary whose scope is extended to the end of the program (see [destructors.scope.lifetime-extension.static]). Such temporaries with interior mutability cannot be borrowed in constant expressions (see [const-eval.const-expr.borrows]).
> Here, the `AtomicU8` is a temporary whose scope is extended to the end of the program (see [destructors.scope.lifetime-extension.exprs.static]). Such temporaries with interior mutability cannot be borrowed in constant expressions (see [const-eval.const-expr.borrows]).
>
> To allow this, we'd have to decide whether each use of the constant creates a new `AtomicU8` or whether each use shares the same lifetime-extended temporary. The latter choice, though closer to how `rustc` thinks about this today, would break the conceptual model that, in most cases, the constant initializer can be thought of as being inlined wherever the constant is used. Since we haven't decided, this is not allowed.

Expand Down
Loading