Welcome to Software Development on Codidact!
Will you help us build our independent community of developers helping developers? We're small and trying to grow. We welcome questions about all aspects of software development, from design to code to QA and more. Got questions? Got answers? Got code you'd like someone to review? Please join us.
What allows a string slice (&str) to outlive its scope?
As a relative newcomer to Rust, I'm trying to understand the behaviour of lifetimes, but I am confused by the following code:
let s: &str = "first";
let mut r: &str = s;
println!("First ref is {}", r);
{
let inner: &str = "second";
r = inner;
}
println!("Second ref is {}", r);
Since we are taking a long-lived reference r
to a string slice inner
which is destroyed at the end of its scope, I was expecting this code to fail to compile with a "variable does not live long enough" error. But to my surprise, it compiles fine, and prints valid output:
First ref is first
Second ref is second
It seems that the reference r
is somehow keeping the "second"
string slice alive beyond the end of the scope in which it is defined, but I haven't seen anything in the book which mentions this. What am I missing?
2 answers
The following users marked this post as Works for me:
User | Comment | Date |
---|---|---|
user56342 | (no comment) | Jul 6, 2022 at 07:33 |
tl;dr, the lifetime of "second"
is static
The heart of your confusion is this:
Since we are taking a long-lived reference r to a string slice inner which is destroyed at the end of its scope,
inner
is gone at the end of its scope, yes. However, its value still lives on.
We have to think about the difference between values and variables. r
doesn't reference inner
, it references "second"
. What's probably confusing you is that inner
has a reference as its value, and the lifetime of the reference is not the same as the lifetime of the referenced value.
r = inner;
This performs a copy of the value of inner
. We are not referencing inner
. Now, what is the value of inner
? "second"
is a string literal. It has a lifetime of static
. Therefore, inner
has a value of type &'static str
.
After the assignment, the value of r
thus also a &'static str
. Indeed, we can actually just specify this and confirm that this compiles cleanly:
let s: &'static str = "first";
let mut r: &'static str = s;
println!("First ref is {}", r);
{
let inner: &'static str = "second";
r = inner;
}
println!("Second ref is {}", r);
If this still confuses you, let's work through a different example:
let s = 1;
let mut r = s;
println!("First r is {}", r);
{
let inner = 2;
r = inner;
}
println!("Second r is {}", r);
This should much more clearly show what's going on. We're just passing around values; who cares that inner
dies at the end of its scope.
I said at the beginning that
r
doesn't referenceinner
, it references"second"
.
What would r
referencing inner
look like? Well, we'd borrow it instead of copying its value. (We also have to borrow s
for parity)
let s: &str = "first";
let mut r: &&str = &s;
println!("First ref is {}", r);
{
let inner: &str = "second";
r = &inner;
}
println!("Second ref is {}", r); // !!!!!
|
7 | r = &inner;
| ^^^^^^ borrowed value does not live long enough
8 | }
| - `inner` dropped here while still borrowed
9 | println!("Second ref is {}", r); // !!!!!
| - borrow later used here
As expected!
This is getting kind of long, but on a final note, look at how this introduces a double reference. Let's think about lifetimes!
let s: &str = "first"; // &'static str
let mut r: &&str = &s; // &'<s> &'static str
println!("First ref is {}", r);
{
let inner: &str = "second"; // &'static str
r = &inner; // &'<inner> &'static str
}
println!("Second ref is {}", r); // !!!!!
For this purpose, '<s>
means "has the same lifetime as s
".
This illustrates the point I made earlier: The lifetime of the reference is not the same as the lifetime of the referenced. At r = &inner;
, we can see that we have those two lifetimes, the lifetime of inner
and the lifetime of "second"
, and those two are not the same.
0 comment threads
There are two things going on here. One which technically explains what's going on fully, and another potential misconception you have.
For the former, &T
implements the Copy
trait regardless of what T
is. So all that's happening is when r = inner;
executes it just does a bitwise copy of the reference which inner
is over r
. It's no different than if r
and inner
had been declared as i64
say. Indeed, you can verify that a move didn't happen by adding code that uses inner
after the r = inner;
line. inner
(trivially) owned a reference to a str
but it never owned the str
itself.
The potential misconception is occurrences of string literals don't mean to dynamically allocate (either on the heap or the stack) a string, and then return a reference to it. Instead, the compiler will statically allocate space for the string literal and all occurrences of the string literal will be replaced with references to this statically allocated memory whose lifetime is the whole program. Formally, this is represented by the 'static
lifetime which subsumes, i.e. outlives, all other lifetimes.
Incidentally, there is some terminological complexity here. The reference that inner
is bound to has a lifetime which, in this example, is the scope containing inner
. Reference types also contain a lifetime, the 'a
in &'a T
, and that is how long the reference type requires the referred to data to live. Finally, the actual lifetime of the referred to data can be any lifetime which subsumes the lifetime the reference requires, i.e. a &'a T
reference can refer to data that has a lifetime of 'b
as long as 'a : 'b
, i.e. 'b
contains 'a
.
In summary, inner
is a short-lived reference to immortal data. s
and r
are slightly longer-lived references to immortal data. r = inner;
is just a bitwise copy of a pointer, and this is safe because the referred to data, being immortal will outlive any lifetime the reference type requires, i.e. 'a : 'static
for all 'a
.
Playing with the following type can help illustrate what's happening.
#[derive(Debug)]
struct NoisyDrop {
i: i64
}
impl Drop for NoisyDrop {
fn drop(&mut self) {
println!("Dropping {}", self.i)
}
}
Since NoisyDrop
does not implement the Copy
trait, no implicit copying will happen.
This first example illustrates what you were expecting, except now that we are locally allocating data, it behaves as you expect.
let s = &NoisyDrop { i: 1 };
let mut r = s;
println!("First ref is {:?}", r);
{
let inner = &NoisyDrop { i: 2 };
r = inner;
}
println!("Second ref is {:?}", r);
This will fail to compile since NoisyDrop { i: 2 }
doesn't live as long as r
's type requires. However, what is actually happening in your example is more like:
let s = &NoisyDrop { i: 1 };
let t = &NoisyDrop { i: 2 };
let mut r = s;
println!("First ref is {:?}", r);
{
let inner = t;
r = inner;
}
println!("Second ref is {:?}", r);
which is unproblematic.
Not directly related to your question, but you may find the output of this final example interesting:
let s = NoisyDrop { i: 1 };
let mut r = s;
println!("First ref is {:?}", r);
{
let inner = NoisyDrop { i: 2 };
println!("Before assign");
r = inner;
println!("After assign");
}
println!("Second ref is {:?}", r);
Here we're actually performing moves of the underlying data which changes who's responsible for calling drop
on the data. This allows data to outlive the scope in which it is allocated. Note that in this example, unlike the others, inner
is no longer usable after r = inner;
.
0 comment threads