Values, Types and Ownership

Article · 14:00

Rust type system is conventional. What is not conventional is ownership: every value has exactly one owner, and when the owner goes out of scope the value is dropped. No collector, no manual free - and a move that is a compile error rather than a crash.

Checked before it runs

Rust source is checked by the borrow checker, which proves at compile time that no value is used after it is freed and no data is shared unsafely across threads, and the compiler then produces a binary that needs no garbage collector.

The compiler argues with you once so the program does not crash later. That trade is the language.

Values, Types and Ownership

Rust's type system is conventional. What is not conventional is ownership: every value has exactly one owner, and when the owner goes out of scope the value is dropped. No garbage collector, no manual free, and no way to use memory after it is released - because the compiler will not let you.

fn main() {
    let count = 42;                 // immutable by default
    let mut total = 0;              // mut to allow reassignment
    let ratio: f64 = 0.5;           // annotate when inference needs help
    let name = "Ada";               // &str: a borrowed string slice
    let owned = String::from("Ada"); // String: owns its buffer

    total += count;

    println!("{count} {total} {ratio} {name} {owned}");
    println!("{:?}", (count, ratio, name));     // Debug formatting
    println!("{:#?}", vec![1, 2, 3]);           // pretty Debug
}

let without mut is the default, and it is the right default: most values never need to change, and saying so lets the compiler help.

Moves: assignment gives the value away

For a type that owns memory - String, Vec<T>, a struct containing them - assignment moves the value. The old name is no longer usable, which is what makes a double free impossible.

fn main() {
    let first = String::from("hello");
    let second = first;             // moved: `first` is no longer valid

    // println!("{first}");         // error[E0382]: borrow of moved value
    println!("{second}");

    // Clone is the explicit, visible copy.
    let third = second.clone();
    println!("{second} and {third}");

    // Small types that fit in a register are Copy: assignment duplicates them.
    let a = 42;
    let b = a;
    println!("{a} {b}");            // both fine: i32 is Copy

    // Passing to a function moves too, unless the type is Copy.
    let text = String::from("given away");
    consume(text);
    // println!("{text}");          // error: value moved into consume

    let number = 7;
    consume_copy(number);
    println!("{number} is still here");
}

fn consume(value: String) {
    println!("consumed {value}");
}

fn consume_copy(value: i32) {
    println!("copied {value}");
}

Borrowing: use without taking

A reference lets you read or modify a value without owning it. The rule the compiler enforces: either one mutable reference, or any number of immutable ones - never both at once.

fn main() {
    let mut text = String::from("hello");

    // Many readers at once is fine.
    let r1 = &text;
    let r2 = &text;
    println!("{r1} {r2} ({} chars)", length(&text));

    // One writer, and no readers while it lives.
    let w = &mut text;
    w.push_str(", world");
    println!("{w}");

    // The original owner can be used again once the borrow ends.
    println!("{text}");

    // This is what the rule prevents:
    //   let r = &text;
    //   let w = &mut text;      // error: cannot borrow as mutable
    //   println!("{r}");        // because r is still in use here
}

fn length(value: &String) -> usize {
    value.len()                     // borrowed, so the caller keeps it
}

That one rule eliminates data races at compile time, which is why Rust can be concurrent without a garbage collector.

&str and String

String owns a growable buffer. &str is a view into one - or into a literal baked into the binary. Take &str in function parameters and you accept both.

fn main() {
    let owned: String = String::from("Ada Lovelace");
    let borrowed: &str = "Ada Lovelace";
    let slice: &str = &owned[0..3];

    println!("{}", greet(&owned));      // a String derefs to &str
    println!("{}", greet(borrowed));
    println!("{}", greet(slice));

    let mut building = String::new();
    for i in 1..=5 {
        building.push_str(&i.to_string());
        building.push(',');
    }
    println!("{building}");

    // Strings are UTF-8, so indexing by byte is forbidden - the compiler will
    // not let you write owned[0]. Iterate by char, or slice on a boundary.
    let unicode = "héllo";
    println!("{} bytes, {} chars", unicode.len(), unicode.chars().count());
    for (i, c) in unicode.char_indices().take(3) {
        println!("  byte {i}: {c}");
    }
}

fn greet(name: &str) -> String {
    format!("Hello, {name}")
}

Numbers, and the overflow the compiler makes you handle

fn main() {
    let a: i32 = 7;
    let b: i32 = 2;

    println!("{} {} {}", a / b, a as f64 / b as f64, a % b);

    // No implicit conversions: `as` is always explicit.
    let big: i64 = a as i64 * 1_000_000_000;
    println!("{big}");

    // In debug builds, overflow panics. In release it wraps. Neither is silent
    // if you use the explicit methods.
    let max = i32::MAX;
    println!("checked:    {:?}", max.checked_add(1));       // None
    println!("saturating: {}", max.saturating_add(1));      // i32::MAX
    println!("wrapping:   {}", max.wrapping_add(1));        // i32::MIN
    let (value, overflowed) = max.overflowing_add(1);
    println!("overflowing: {value} (overflowed: {overflowed})");

    // Floats are binary approximations, here as everywhere.
    println!("{}", 0.1 + 0.2);
    println!("{}", (0.1f64 + 0.2 - 0.3).abs() < f64::EPSILON);
}

What to take away

  • Values are immutable unless declared mut, and that is the useful default.
  • Assignment moves an owning value; the old name stops compiling, not crashing.
  • Copy types duplicate instead of moving; clone() is the explicit deep copy.
  • Borrowing: many &T, or one &mut T, never both - which is what kills data races.
  • Take &str in parameters and you accept both String and a literal.
  • Overflow panics in debug and wraps in release: use checked_, saturating_ or wrapping_.