cv.cm

Coding · Programming languages

Rust tutorial for beginners

7 min read

Short answer

Rust is a compiled, statically typed systems language that is as fast as C and C++ but prevents memory errors at compile time, without a garbage collector. Install it with rustup, run cargo new hello and cargo run. The core ideas are ownership and borrowing (checked by the borrow checker), structs and enums with match, collections such as Vec and HashMap, and error handling with Result and the ? operator.

Open Binary converter

What Rust is and what it is used for

Rust started as a personal project of Graydon Hoare, was sponsored by Mozilla, and reached version 1.0 in 2015. It is now stewarded by the Rust Foundation. Rust is used where speed and reliability both matter: command-line tools (ripgrep), web services, browser components in Firefox, parts of the Linux kernel and Windows, embedded devices and WebAssembly. Its compiler is strict, but in return whole classes of bugs, such as use-after-free and data races, cannot happen in safe Rust code.

Install Rust and run Hello, world

Install the toolchain with rustup from rustup.rs. It provides rustc (the compiler) and Cargo (the build tool and package manager). Create a project:

cargo new hello
cd hello
cargo run
# Hello, world!

Cargo generates src/main.rs:

fn main() {
    println!("Hello, world!");
}

println! ends with ! because it is a macro, not a function. Macros generate code at compile time; here, the format string is checked when you compile.

Variables and basic types

Variables are immutable by default; add mut to allow changes. Types are usually inferred, but every value has exactly one static type, and numeric conversions are explicit with as.

TypeExampleNotes
i32, i64, u8, usizelet n: i32 = 42;Signed (i) and unsigned (u); usize for indexes
f64let x = 3.14;Default float type
boollet ok = true;true or false
charlet c = 'é';A Unicode scalar value, 4 bytes
&strlet s = "hi";Borrowed string slice
StringString::from("hi")Owned, growable UTF-8 text
Tuple, array(1, "a"), [0; 3]Fixed size
fn main() {
    let name = "Ada";
    let mut count = 7;
    count += 1;
    let avg = count as f64 / 2.0;
    println!("{name} has {count} items, avg {avg}"); // Ada has 8 items, avg 4
    let parsed: i32 = "12".parse().unwrap();
    println!("{}", parsed + 1); // 13
}

Control flow and functions

if, match and blocks are expressions: they produce values. The last expression of a function, without a semicolon, is its return value.

fn square(x: i32) -> i32 {
    x * x
}

fn main() {
    let score = 72;
    let grade = if score >= 90 { "A" } else if score >= 70 { "B" } else { "C" };
    println!("{grade}"); // B

    for i in 0..3 {
        print!("{} ", square(i)); // 0 1 4
    }
    println!();

    let mut n = 10;
    while n > 1 {
        n /= 2;
    }
    let label = loop {
        break "done"; // loop can return a value with break
    };
    println!("{n} {label}"); // 1 done
}

Signature feature: ownership and borrowing

Each value has exactly one owner. When the owner goes out of scope, the value is dropped (freed). Assigning a heap value such as a String to another variable moves it, and the old name can no longer be used. To use a value without taking ownership, borrow it with a reference: any number of shared references &T, or exactly one mutable reference &mut T, at a time.

fn length(s: &str) -> usize {
    s.len() // borrows, does not take ownership
}

fn add_exclamation(s: &mut String) {
    s.push('!');
}

fn main() {
    let a = String::from("hello");
    let b = a; // ownership moves to b
    // println!("{a}"); // error: borrow of moved value: `a`
    let mut c = b.clone(); // explicit deep copy
    add_exclamation(&mut c);
    println!("{} {} {}", b, c, length(&c)); // hello hello! 6
}

These rules are enforced by the borrow checker at compile time, which is how Rust achieves memory safety without a garbage collector. Simple types such as integers implement Copy, so they are copied instead of moved.

Structs, enums and pattern matching

Structs group fields, and impl blocks add methods. Enums can carry data in each variant, and match must handle every variant, which the compiler checks. The standard Option<T> enum (Some(value) or None) replaces null.

#[derive(Debug)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
    fn distance(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }
}

enum Shape {
    Circle(f64),
    Rect { w: f64, h: f64 },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

fn main() {
    let p = Point { x: 3.0, y: 4.0 };
    println!("{:?} {}", p, p.distance()); // Point { x: 3.0, y: 4.0 } 5
    println!("{}", area(&Shape::Rect { w: 2.0, h: 3.0 })); // 6
    let nums = [10, 20];
    if let Some(v) = nums.first() {
        println!("first is {v}"); // first is 10
    }
}

Traits define shared behavior, similar to interfaces: #[derive(Debug)] asks the compiler to implement the Debug trait so {:?} can print the struct.

Collections: Vec and HashMap

use std::collections::HashMap;

fn main() {
    let mut nums = vec![3, 1, 2];
    nums.push(5);
    nums.sort();
    println!("{:?} {}", nums, nums.len()); // [1, 2, 3, 5] 4

    let evens: Vec<i32> = nums.iter().filter(|n| *n % 2 == 0).map(|n| n * n).collect();
    println!("{:?}", evens); // [4]

    let mut ages: HashMap<&str, u32> = HashMap::new();
    ages.insert("ann", 31);
    ages.insert("bob", 25);
    *ages.entry("ann").or_insert(0) += 1;
    println!("{:?} {:?}", ages.get("ann"), ages.get("zed")); // Some(32) None
}

Iterators with closures (|n| ...) such as filter, map and collect compile to code as fast as a hand-written loop.

Error handling with Result and ?

Functions that can fail return Result<T, E>: either Ok(value) or Err(error). The ? operator returns the error to the caller early, so error paths stay short. Unrecoverable bugs use panic!.

use std::num::ParseIntError;

fn parse_age(text: &str) -> Result<u32, ParseIntError> {
    let age: u32 = text.trim().parse()?; // returns Err early on bad input
    Ok(age)
}

fn main() {
    match parse_age("41") {
        Ok(age) => println!("age {age}"), // age 41
        Err(e) => println!("error: {e}"),
    }
    match parse_age("abc") {
        Ok(age) => println!("age {age}"),
        Err(e) => println!("error: {e}"), // error: invalid digit found in string
    }
}

A small complete program: word frequency

Put this in src/main.rs and run cargo run -- notes.txt.

use std::collections::HashMap;
use std::{env, fs, process};

fn main() {
    let path = env::args().nth(1).unwrap_or_else(|| {
        eprintln!("usage: words FILE");
        process::exit(1);
    });
    let text = match fs::read_to_string(&path) {
        Ok(t) => t.to_lowercase(),
        Err(e) => {
            eprintln!("cannot read {path}: {e}");
            process::exit(1);
        }
    };

    let mut counts: HashMap<&str, usize> = HashMap::new();
    for word in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
        *counts.entry(word).or_insert(0) += 1;
    }

    let mut top: Vec<(&str, usize)> = counts.into_iter().collect();
    top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
    for (word, n) in top.iter().take(5) {
        println!("{n:5}  {word}");
    }
}

The HashMap stores &str slices that borrow from text, so no words are copied; the borrow checker guarantees that text lives long enough.

Common beginner mistakes

  • Using a value after it was moved. Borrow it (&value) or clone it on purpose.
  • Trying to index a String with s[0]. Strings are UTF-8, so use s.chars() or byte slices at character boundaries.
  • Holding a reference into a Vec while pushing to it; the push may reallocate, so the compiler forbids it.
  • Confusing String (owned) and &str (borrowed). Accept &str in function parameters to allow both.

Where to go next

Read "The Rust Programming Language" (known as the book), available free on doc.rust-lang.org, and try Rust by Example for short runnable samples. Then learn traits and generics in depth, lifetimes, testing with cargo test, and crates from crates.io such as serde for serialization and tokio for async programs. Run cargo clippy for extra lint advice.

FAQ

Is Rust hard to learn?

The first weeks are harder than in most languages because ownership and borrowing are new ideas. After they become familiar, the compiler catches many bugs early, and many developers find Rust code easier to maintain.

Does Rust have a garbage collector?

No. Memory is freed automatically when its owner goes out of scope, as decided at compile time by the ownership rules. Reference-counted types Rc and Arc exist for shared ownership.

What is unsafe Rust?

Code inside an unsafe block may do things the compiler cannot verify, such as dereferencing raw pointers or calling C functions. It is used sparingly, usually wrapped in a safe API.

Rust or Go: which should I learn?

Go is simpler and faster to learn and is popular for network services. Rust gives more control and performance with stronger guarantees, at the cost of a steeper learning curve.

All coding tutorials