Rust Scopes, References, Sized Types, Lifetimes, and Ownership
Scopes in Rust
In Rust, scope is an important concept that determines where variables are accessible. Let's explore this with some code examples:
fn main() {
// This defines `i` in the scope of the main function.
// It won't be accessible from a different function.
let i = 5;
println!("{i}");
// This creates a new scope for the loop.
// The curly braces start a new scope for the variables defined in the loop.
for i in 1..5 {
println!("{}", i);
}
// You can also create just a scope with curly braces
// and define variables that are scoped to that block.
{
let name = String::new();
println!("{}", name);
}
// This won't compile and will give an error:
// println!("{}", name);
}
Understanding scope is crucial for working with references and lifetimes in Rust.
References in Rust
References allow us to give access to a value through another variable. Here's how they work:
let name = String::from("akbar");
println!("{name}");
// We can create a reference to the above variable
let n = &name;
// `n` gets assigned the address of the value
// While you often need to explicitly dereference references in Rust,
// certain operations like println! have built-in behavior to
// automatically dereference when it makes sense to do so.
println!("{}", n);
println!("{}", *n);
// This returns the memory address of n
println!("{:p}", n);
The Display Trait and References
Let's connect this to the Display
trait:
let name = String::from("akbar");
let n = &name;
println!("{}", n);
Here's what's happening behind the scenes:
n
is a&String
println!
looks for aDisplay
implementation for&String
- It doesn't find one, so it dereferences
&String
toString
- It still doesn't find a
Display
implementation forString
, so it dereferences again - Now it has a
str
, which does have aDisplay
implementation - It uses this implementation to print the string
This multi-step dereferencing happens automatically, which is why you don't need to write *n
explicitly. This automatic dereferencing is due to a feature called "deref coercion", which allows you to use a &String
where a &str
is expected without explicit dereferencing.
Sized and Unsized Types in Rust
Sized vs. Unsized Types:
- A sized type is one whose size is known at compile time. For example,
i32
,f64
, or any struct with fields of known size. - An unsized type is one whose size is not known at compile time. These are also called "dynamically sized types" (DSTs).
Why str
is Unsized:
str
represents a string slice of any length. Since strings can be of any length, the compiler doesn't know how much memory a str
will occupy at compile time.
Why We Use &str
:
While we can't use str
directly (because its size is unknown), we can use a reference to it: &str
.
A reference to an unsized type is itself a sized type. It consists of two parts:
- A pointer to the data
- Additional information about the unsized type (in this case, the length of the string)
This combination of pointer and metadata is known as a "fat pointer" and has a known size at compile time.
How References Make it Sized:
For &str
, the reference contains:
- A pointer to the start of the string data
- The length of the string
This reference has a fixed size (typically two words: one for the pointer, one for the length), which is known at compile time.
Example:
// let s: str = "Hello, world!"; // This is not allowed!
let s: &str = "Hello, world!"; // This is fine
In the commented-out line, we can't create a variable of type str
directly because Rust wouldn't know how much stack space to allocate for it.
In the second line, we're creating a reference to a string slice, which has a known size (typically 16 bytes on a 64-bit system: 8 for the pointer, 8 for the length).
Lifetimes
Lifetimes are Rust's way of ensuring that references are valid for as long as they're used. Here's an example that demonstrates a lifetime issue:
let n: &String;
{
let name = String::from("akbar");
n = &name; // this would give an error
}
println!("{n}");
This code would result in a compile-time error:
error[E0597]: `name` does not live long enough
--> src/main.rs:116:9
|
115 | let name = String::from("akbar");
| ---- binding `name` declared here
116 | n = &name;
| ^^^^^ borrowed value does not live long enough
The error occurs because name
goes out of scope at the end of the inner block, but we're trying to use a reference to it outside of that scope.
Ownership
Rust uses an ownership system to manage memory without a garbage collector. Here are some key points:
- There is only a single owner for each piece of data.
- When the owner goes out of scope, the data is automatically deallocated.
Let's look at an example:
let first = String::from("Akbar");
let name = first;
println!("{}", first); // this would give an error
In this case, the ownership of the string data has moved from first
to name
. After this move, first
is no longer valid to use. Rust prevents us from using moved values to avoid potential bugs.
If we try to compile this code, we'd get an error message like this:
error[E0382]: borrow of moved value: `first`
--> src/main.rs:134:20
|
131 | let first = String::from("Akbar");
| ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
132 | let name = first;
| ----- value moved here
133 |
134 | println!("{}", first); // this would give error
| ^^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
132 | let name = first.clone();
| ++++++++
This ownership model applies to data created on the heap, like String
. For primitive types that are stored on the stack, Rust uses copying instead of moving:
let score = 50;
let points = score;
println!("{}", score); // this will print
In this case, score
is copied to points
, and both variables remain usable because primitive types implement the Copy
trait.
Understanding these concepts of scopes, references, sized types, lifetimes, and ownership is crucial for writing efficient and safe Rust code. Rust's strict rules might seem challenging at first, but they prevent many common programming errors at compile-time, leading to more robust and secure software.