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:

  1. n is a &String
  2. println! looks for a Display implementation for &String
  3. It doesn't find one, so it dereferences &String to String
  4. It still doesn't find a Display implementation for String, so it dereferences again
  5. Now it has a str, which does have a Display implementation
  6. 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:

  1. A pointer to the data
  2. 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:

  1. A pointer to the start of the string data
  2. 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:

  1. There is only a single owner for each piece of data.
  2. 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.

Subscribe to maventype

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe