Lifetime in Rust
Amit Nadiger
Polyglot(Rust??, C++ 11,14,17,20, C, Kotlin, Java) Android TV, Cas, Blockchain, Polkadot, UTXO, Substrate, Wasm, Proxy-wasm,AndroidTV, Dvb, STB, Linux, Engineering management.
Understanding lifetime concepts in Rust, particularly for individuals coming from high-level languages like Java, can indeed pose challenges. The syntax associated with lifetime annotations, denoted by names preceded by apostrophes ('a, 'b, etc.), may appear unfamiliar and intricate. This complexity is further pronounced for developers accustomed to languages like Java, where memory management is abstracted and explicit lifetime management is not a concern.
In languages like C and C++, where manual memory management and pointer manipulation are more prevalent, developers might find certain aspects of Rust's lifetime concepts more approachable. Pointers and the associated syntax are more familiar to those with experience in low-level languages.
The syntax for lifetime annotations, though crucial for expressing ownership and borrowing relationships in Rust, can be perceived as less aesthetically pleasing or less immediately intuitive compared to the syntax in other high-level languages.
For developers transitioning to Rust from languages with garbage collection, the need for explicit lifetime annotations might initially feel burdensome. However, it's essential to recognize that Rust's approach to lifetimes is a key component of its ownership system, contributing significantly to memory safety without relying on garbage collection.
To facilitate the learning process, developers coming from high-level languages may benefit from breaking down lifetime annotations into their basic components. Understanding that 'a represents a named lifetime and &'a T represents a reference with a specific lifetime to an object of type T is a fundamental step.
Despite the initial challenges, grasping the interplay between lifetimes, ownership, and borrowing is pivotal for writing safe and concurrent Rust code. Practice with simpler examples, reading code with lifetimes, and gradually exploring more advanced topics can contribute to a deeper understanding over time.
Introduction to lifetime in Rust:
lifetimes are a key concept in the ownership system, playing a crucial role in preventing memory-related bugs without the need for a garbage collector. Understanding lifetimes is essential for writing safe and concurrent Rust code.
Lifetimes in Rust ensure that references are used in a safe and predictable manner, preventing issues like dangling references and data races. While understanding lifetimes can be challenging initially, they are a powerful tool for writing robust and safe concurrent code in Rust.
Ownership and Borrowing Recap:
What Are Lifetimes?
Lifetimes are annotations that specify the scope for which references are valid. They ensure that references used in your code are valid for a certain duration and prevent the creation of dangling references (references to data that no longer exists).
A borrowed value has a?lifetime Rust:
Lifetimes are a way to specify the scope of a reference, and they ensure that borrowed data remains valid for the duration of the reference.
In Rust, every value and reference have a lifetime associated with it, which determines the scope of the borrowed data. This is important because Rust needs to ensure that values are not used after they have been deallocated, which can cause memory safety issues such as use-after-free bugs. Lifetimes are denoted by a single quote character or apostrophes ('), followed by a name that represents the lifetime. A reference with a lifetime indicates that it borrows a value for a specific amount of time, and it cannot outlive the value it borrows.
Lets gothrough one by one:
1.Elided lifetimes:
In the context of Rust and its lifetime system, "elision" refers to the automatic inference or omission of lifetime annotations in function signatures. Rust has a set of rules for lifetime elision that allow the compiler to deduce certain lifetime relationships without requiring explicit annotations in the code. This helps to make the code more concise and readable.
Lifetime elision is applied to function signatures when dealing with references, particularly in situations where the lifetimes are straightforward and can be inferred based on the structure of the function.
The primary goal of lifetime elision is to reduce the verbosity of the code while still maintaining clarity and correctness. It simplifies the process of writing functions that involve references, making it more ergonomic for developers.
Here are some key points about lifetime elision in Rust:
The compiler uses three rules to figure out the lifetimes of the references when there aren’t explicit annotations. The first rule applies to input lifetimes, and the second and third rules apply to output lifetimes. If the compiler gets to the end of the three rules and there are still references for which it can’t figure out lifetimes, the compiler will stop with an error. These rules apply to fn definitions as well as impl blocks.
1. Each parameter gets its own lifetime parameter:
fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
// function body
}
2. If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters:
If a function or method has exactly one input lifetime parameter, that lifetime is automatically assigned to all output lifetime parameters.Example:
fn bar<'a>(x: &'a str, y: &'a str) -> &'a str {
// function body
}
3. If there are multiple input lifetime parameters, but one of them is &self or &mut self, the lifetime of self is assigned to all output lifetime parameters:
struct Foo<'a>(&'a str);
impl<'a> Foo<'a> {
fn get_data(&self) -> &'a str {
self.0
}
}
These elision rules help reduce the need for explicit lifetime annotations in many common cases, making Rust code more concise and readable. However, there are situations where explicit lifetime annotations are still required, especially in more complex scenarios involving structs, trait bounds, and associated types.
In Rust, you can omit explicit lifetime annotations in many cases, because the compiler will infer them for you. For example, in the function signature add(p1: &Point, p2: &Point) -> Point, the lifetime of p1 and p2 is not explicitly specified, but the compiler will infer that they have the same lifetime. This is because they are both references passed as function arguments, and Rust assumes that the lifetime of the references should be the same as the lifetime of the function arguments.
No issues with the below code, The Compiler properly derived lifetime automatically i.e "lifetime elision" is used.
struct Point {
? ? x: i32,
? ? y: i32,
}
fn add(p1: &Point, p2: &Point) -> Point {
? ? Point { x: p1.x + p2.x, y: p1.y + p2.y }
}
fn main() {
? ? let p1 = Point { x: 1, y: 2 };
? ? let p2 = Point { x: 3, y: 4 };
? ? let p3 = add(&p1, &p2);
? ? println!("Result: ({}, {})", p3.x, p3.y);
}
/*
Result: (4, 6)
*/
In the above example, the lifetime of the reference to the Point values passed to the add function is not explicitly specified, but is instead inferred by the compiler. This is known as "lifetime elision".
Lets see the "lifetime elision" where compiler catches issues.
Consider below example :
fn longest_elided(s1: &str, s2: &str) -> &str {
? ? if s1.len() > s2.len() {
? ? ? ? s1
? ? } else {
? ? ? ? s2
? ? }
}
// The longest_elided() is abused in below code.
fn main() {
? ? let result;
? ? {
? ? ? ? let s1 = String::from("abc");
? ? ? ? let s2 = String::from("xyz");
? ? ? ? result = longest_elide(&s1, &s2);
? ? }
? ? // The references in 'result' are now dangling!
? ? println!("Result: {}", result);
}
Compiler error :
The longest_elided()?function itself doesn't lead to memory corruption or any undefined behavior.? it's a correct and safe implementation.?
But how this function is used in main() is having the problem.
Let's correct the above issues in the main code and see what happens.
fn longest_elided(s1: &str, s2: &str) -> &str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
// The longest_elided() is not abused in below code.
fn main() {
let s1 = String::from("abc");
let s2 = String::from("xyz");
let result = longest_elided(&s1, &s2);
// The references in 'result' are now dangling!
println!("Result: {}", result);
}
/*
Compilation error:
error[E0106]: missing lifetime specifier
--> main.rs:1:42
|
1 | fn longest_elided(s1: &str, s2: &str) -> &str {
| ^ expected lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `s1` or `s2`
*/
Even though the longest_elided() and its usage in the main() is correct, still compilation issues happen. Why?
Rust has lifetime elision rules that allow it to automatically infer lifetimes in certain common scenarios,
If the function doesn't fit those specific patterns, the compiler requires us to provide explicit lifetime annotations.
The above function longest_elided() doesn't fit in to a specific pattern where rust can automatically infer lifetime.
Hence compilation issues happen.
Then what is the solution of this problem ??
Answer is : explicit lifetime annotations
2. Explicit lifetimes:
We can also specify lifetimes explicitly using the 'a syntax. For example, if you want to create a function that returns a reference to a String with a lifetime 'a, you can annotate the function signature as fn longest_explicit<'a>(s1: &'a str, s2: &'a str) -> &'a str . This tells the compiler that the returned reference should have a lifetime of 'a, which means that it must be valid for at least as long as the lifetime 'a.
fn longest_explicit<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
// Here main function remains same as earlier.
// The longest_explicit() is not abused in below code.
fn main() {
let s1 = String::from("abc");
let s2 = String::from("xyz");
let result = longest_explicit(&s1, &s2);
println!("Result: {}", result);
}
// O/P -> Result: xyz
Here, 'a is a lifetime parameter. The function longest_explicit takes two string slices with the same lifetime 'a and returns a string slice with the same lifetime 'a.
2.1 Multiple Explicit Lifetime Annotations:
#[derive(Debug, Clone)]
struct Resource(String);
fn get_ref(s: &Resource, t: &Resource) -> &Resource{
s
}
fn main() {
let s = Resource("Hello".to_string());
let t = get_ref(&s);
}
/*
error[E0106]: missing lifetime specifier
--> main.rs:4:35
|
4 | fn get_ref(s: &Resource, t: &Resource) -> &Resource{
| ^ expected lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `s` or `t`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0106`.
*/
As you know above code fails to compile, demanding that we name the lifetimes. We can fix that:
Let's fix this issue with multiple explicit annotations:
#[derive(Debug, Clone)]
struct Resource(String);
fn get_ref<'a, 'b>(s: &'a Resource, t: &'b Resource) -> &'a Resource{
s
}
fn main() {
let s = Resource("Hello".to_string());
let t = get_ref(&s, &s);
println!("{:?}",t);
}
The above code worsk well.
Now Let's try to create a dangling pointer in the above code and see if rust compiler can catch this issue.
#[derive(Debug, Clone)]
struct Resource(String);
fn get_ref<'a, 'b>(s: &'a Resource, t: &'b Resource) -> &'a Resource {
s
}
fn main() {
let s = Resource("Hello".to_string());
let t = get_ref(&s, &s);
println!("{:?}",t);
std::mem::drop(s);
println!("{:?}",t);
}
/*
warning: unused variable: `t`
--> main.rs:4:37
|
4 | fn get_ref<'a, 'b>(s: &'a Resource, t: &'b Resource) -> &'a Resource {
| ^ help: consider prefixing with an underscore: `_t`
|
= note: `#[warn(unused_variables)]` on by default
error[E0505]: cannot move out of `s` because it is borrowed
--> main.rs:12:20
|
10 | let t = get_ref(&s, &s);
| -- borrow of `s` occurs here
11 | println!("{:?}",t);
12 | std::mem::drop(s);
| ^ move out of `s` occurs here
13 | println!("{:?}",t);
| - borrow later used here
error: aborting due to previous error
For more information about this error, try `rustc --explain E0505`.
*/
Calling std::mem::drop explicitly destroys (and calls the destructor) of any variable. It's the same as delete in C++ or free in C---the variable is gone. C++ would let you run this, and crash on execution. Rust won't allow it to compile:
Now let's see if the longest_explicit() can catch the different types of misuse.
fn longest_explicit<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
// The longest_explicit() is abused in below code.
fn main() {
let result;
{
let s1 = String::from("abc");
let s2 = String::from("xyz");
result = longest_explicit(&s1, &s2);
}
println!("Result: {}", result);
}
/*
rror[E0597]: `s1` does not live long enough
--> main.rs:50:35
|
50 | result = longest_explicit(&s1, &s2);
| ^^^ borrowed value does not live long enough
51 | }
| - `s1` dropped here while still borrowed
52 | println!("Result: {}", result);
| ------ borrow later used here
error[E0597]: `s2` does not live long enough
--> main.rs:50:40
|
50 | result = longest_explicit(&s1, &s2);
| ^^^ borrowed value does not live long enough
51 | }
| - `s2` dropped here while still borrowed
52 | println!("Result: {}", result);
| ------ borrow later used here
*/
Perfect!! Compilation error . It catches issues related to incorrect lifetime.
2.1 Multiple Explicit Lifetime Annotations:
In Rust, multiple explicit lifetime annotations are often used when dealing with functions or structs that involve multiple references, each with its own distinct lifetime. Here's an example of a function that takes two string slices with different lifetimes:
// A function that returns the longer of two string slices
fn longer_string<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
fn main() {
let s1 = "apple";
let s2 = "orange";
{
let result = longer_string(s1, s2);
println!("The longer string is: {}", result);
// At this point, s1 and s2 are still valid
}
// Outside the block, s1 and s2 are still valid
}
/*
Compiler error :
error[E0623]: lifetime mismatch
--> main.rs:6:9
|
2 | fn longer_string<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str {
| ------- -------
| |
| this parameter and the return type are declared with different lifetimes...
...
6 | s2
| ^^ ...but data from `s2` is returned here
*/
How we can fix the above issue ?
Here comes Lifetime Subtyping:
Lifetime Subtyping or Lifetime constraints:
Lifetimes in Rust follow subtyping, meaning that if a reference with a longer lifetime is required, a reference with a shorter lifetime can be used in its place.
Lifetime subtyping refers to the relationship between lifetimes, where one lifetime is considered to be a subtype of another. If a shorter-lived reference is used in a context that expects a longer-lived reference, Rust allows this subtyping.
As explained above when using explicit lifetimes, we can create constraints on the relationships between lifetimes. For example, if you have a function that takes two references with different lifetimes, you can specify that the returned reference must have a lifetime that is at least as long as both of the input lifetimes. This is done using the syntax 'a: 'b, which means that the lifetime 'a is at least as long as the lifetime 'b.
In below code we are telling 'b: 'a, which means that the lifetime 'b is at least as long as the lifetime 'a.
// A function that returns the longer of two string slices
fn longer_string<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str
where 'b:'a
{
if s1.len() > s2.len() {
s1
} else {
s2
}
}
fn main() {
let s1 = "apple";
let s2 = "orange";
{
let result = longer_string(s1, s2);
println!("The longer string is: {}", result);
// At this point, s1 and s2 are still valid
}
// Outside the block, s1 and s2 are still valid
}
// Op => The longer string is: apple
Here, the function longer_string takes two string references with lifetimes 'a and 'b, respectively. The where 'b: 'a clause specifies that 'b must outlive 'a. This ensures that if print_longest returns a reference, it will have the same or longer lifetime than the reference passed as s1.
Below are some of important concepts w.r.t rust's lifetime:
Static Lifetime: 'static
The 'static lifetime in Rust represents the entire duration of the program. It is the longest possible lifetime and is used for references that are expected to live for the entire duration of the program.
fn static_lifetime() -> &'static str {
"Hello, world!"
}
Here, the function static_lifetime returns a reference (&'static str) to a string literal. The lifetime 'static signifies that the returned reference is valid for the entire program's execution.
Lifetime Subtyping or Lifetime constraints:
Already explained earlier
Lifetime Bounds:
Lifetime bounds are used to specify relationships between the lifetimes of references in generic types or functions. They ensure that references with certain lifetimes are used correctly within the context of the generic type or function.
Example with a struct:
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn new(part: &'a str) -> Self {
ImportantExcerpt { part }
}
fn get_part(&self) -> &'a str {
self.part
}
}
In this example, the struct ImportantExcerpt has a lifetime parameter 'a. The new method takes a reference with lifetime 'a, and the get_part method returns a reference with the same lifetime 'a. This ensures that the references inside the struct adhere to the specified lifetime.
Example of static lifetime,lifetime subtyping and lifetime bound.:
// Function with the 'static lifetime
fn static_lifetime() -> &'static str {
"Hello, world!"
}
// Function with lifetime subtyping
fn print_longest<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str
where
'b: 'a,
{
if s1.len() > s2.len() {
s1
} else {
s2
}
}
// Struct with a lifetime bound
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
// Method with a lifetime bound
fn new(part: &'a str) -> Self {
ImportantExcerpt { part }
}
// Another method with a different lifetime
fn get_part(&self) -> &'a str {
self.part
}
}
fn main() {
// Using the 'static lifetime
let static_str: &'static str = static_lifetime();
println!("Static Lifetime: {}", static_str);
// Using lifetime subtyping
let s1 = "Rust";
let s2 = String::from("Lifetimes");
let longest_str = print_longest(s1, &s2);
println!("Longest String: {}", longest_str);
// Using lifetime bounds in a struct
let novel = String::from("This is a novel.");
let first_sentence = novel.split('.')
.next()
.expect("Could not find a '.'");
let excerpt = ImportantExcerpt::new(first_sentence);
println!("Important Excerpt: {}", excerpt.get_part());
}
/*
Static Lifetime: Hello, world!
Longest String: Lifetimes
Important Excerpt: This is a novel
*/
----------------------------------------------------------------
A lifetime is a construct that describes the duration for which a reference is valid. Every reference in Rust has a lifetime associated with it, which determines the maximum duration for which the reference can be used.
fn main() {
? ? let x = 5;
? ? // borrow `x` with a lifetime of `'a`
? ? fn foo<'a>(y: &'a i32) -> &'a i32 {
? ? ? ? y
? ? }
? ? let y = foo(&x);
? ? println!("y = {}", y);
}
/*
Op =>
amit@DESKTOP-9LTOFUP:~/OmPracticeRust$ ./OwnerShip
y = 5
*/
In the above example, we define a function foo that takes a reference to an i32 with a lifetime of 'a. The function simply returns the reference it receives. When we call foo with &x, the reference y that is returned has the same lifetime as x. This ensures that y remains valid for at most as long as x.
Ownership and Lifetimes in Structs
Consider a scenario where struct holds a reference to data that goes out of scope, resulting in a lifetime issue. I'll then show how to address this problem using explicit lifetime annotations and borrowing.
Consider the following code:
struct MyStruct<'a> {
data: &'a str,
}
fn main() {
let my_struct_with_localdata;
{
let local_data = String::from("Local data");
my_struct_with_localdata = MyStruct { data: &local_data };
} // local_data goes out of scope here
println!("Data: {}", my_struct_with_localdata.data);
}
/*
Leads to below error :
error[E0597]: `local_data` does not live long enough
--> src/main.rs:10:53
|
9 | let local_data = String::from("Local data");
| ---------- binding `local_data` declared here
10 | my_struct_with_localdata = MyStruct { data: &local_data };
| ^^^^^^^^^^^ borrowed value does not live long enough
11 |
12 | } // local_data goes out of scope here
| - `local_data` dropped here while still borrowed
13 | println!("Data: {}", my_struct_with_localdata.data);
| ----------------------------- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `immutableBarrow` (bin "immutableBarrow") due to previous error
*/
Structs in Rust can contain references as fields. When a struct contains a reference, the lifetime of the reference must be specified in the struct definition. This allows the compiler to ensure that the reference is valid for as long as the struct is in use.
struct Foo<'a> {
? ? x: &'a i32,
}
fn main() {
? ? let x = 5;
? ? // create a `Foo` struct with a reference to `x`
? ? let f = Foo { x: &x };
? ? // print the value of `x` through the reference in `f`
? ? println!("x = {}", f.x);
}
In the above example, we define a struct Foo that contains a reference to an i32 with a lifetime of 'a. When we create an instance of Foo with Foo { x: &x }, the lifetime of the reference &x is specified to be the same as the lifetime of the Foo instance. This ensures that the reference remains valid for duration of the x in main .
Notes:
In summary, borrowing and lifetimes are essential concepts in Rust that allow for safe and efficient memory management. Rust enforces strict rules to ensure that a value is not used after it has been moved or mutated, preventing common programming errors like null pointer dereferencing, data races, and memory leaks. By using the borrow checker, Rust can catch these errors at compile time rather than runtime, making it a more reliable and secure language.
Rust also supports lifetime annotations that help the compiler ensure that references are not used after their original data is no longer valid.
While lifetimes can be explicit or elided, the Rust compiler always infers them automatically. This ensures that code is efficient and correct, without requiring the programmer to manually manage the lifetimes of all variables and references. By using these concepts, Rust provides memory safety and performance without requiring garbage collection or other runtime overhead.
Thanks for reading till end , please comment if you have any.
Software Engineer Rust Cryptography Blockchain Javascript Python and others
1 年Good take on lifetimes. This feature is one of the reasons why Rust is considered a "hard" language. New developers need to understand that using lifetimes is not essential. We use it when we need to optimize for performance. I usually recommend creating a copy first. Then, if it works, we implement lifetimes to avoid having that extra copy.