Skip to main content

Command Palette

Search for a command to run...

Mastering Rust Programming: A Beginner's Guide

A Comprehensive Rust Guide: Start from Basics to Traits, Ownership, and Concurrency

Published
•25 min read•View as Markdown
Mastering Rust Programming: A Beginner's Guide

Why Rust?

  • Memory Safety - No null pointer, no dangling pointer, no data races.

  • High Performance - Offers performance close C/C++, great for System-level code.

  • Fearless Concurrency - Safe, easy multithreading without headaches.

  • Developer Experience - Friendly compiler errors, modern tooling (Cargo), and a supportive community.

Rust Basics

Variables and mutability (let, mut, const)

Rust lets you declare or instanstaniate a variable similar to typescript but with some major differences.

let fruit = "Apple";
fruit = "Orange" // throws you an error saying this variable it not mutable

By default, variables in Rust are immutable, meaning once you assign a value, it’s locked—you can’t change it unless you explicitly tell Rust otherwise. To make a variable flexible, you add the keyword mut when declaring it, letting the compiler know it’s allowed to change. But here’s the twist: Rust’s variable declarations come with a sneaky catch we’ll uncover a bit later.

let mut fruit = "Apple";
fruit = "Orange" // successfully compiles

In Rust, const works much like in TypeScript—you must initialize it when declaring, and its value can never change. The only difference is in style: Rust’s convention is to name constants in ALL_CAPS_WITH_UNDERSCORES, just to make sure they stand out like the unshakable values they are.

const HEALTHY_FRUIT = "Apple";

Data types (scalar, compound)

A scalar type refers to the basic primitive data types like integers, floating-point numbers, Booleans, and characters. In contrast, compound types are formed by combining multiple values—either scalars or even other compound types—into a single unit.

Integer Types

LengthSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128

Generally the bits are represented in two basic structure signed and unsigned bits let me explain you what exactly this means…

  • A signed integer (i8, i16, etc.) ranges from −(2^(n−1)) to 2^(n−1) − 1. For example, an i8 covers −128 to 127. The reason it stops at n−1? The very first bit is reserved to carry the number’s mood—positive or negative—so you lose one bit of range.

  • An unsigned integer (u8, u16, etc.) plays it simple: 0 to 2^n − 1. A u8 goes from 0 to 255 because there’s no sign bit hogging space. Every bit is dedicated to counting upwards, which makes unsigned the cheerful cousin that only deals with positive vibes.


Float Types

Rust offers two main floating-point types: f32 and f64. Their ranges work similarly to integers, depending on the size you choose. By default, Rust uses f64 for better precision, but you can explicitly declare f32 when you need a smaller, faster option.

let x = 2.0;      // f64 by default  
let y: f32 = 3.0; // explicitly f32

Boolean Type

Rust’s boolean type is represented by bool and can hold only two values: true or false. It’s most commonly used in conditions and control flow.

let is_active: bool = true;  
let is_admin = false; // type inferred as bool

Character Type

Rust’s character type is char, and unlike some languages, it represents a Unicode scalar value, not just ASCII. That means it can store letters, numbers, symbols, and even emojis.

let letter: char = 'R';  
let symbol = '#';       // type inferred as char  
let emoji = '🔥';

Touple Type

In Rust, a tuple is a compound type that can group together values of different types into a single structure. Tuples have a fixed size—once declared, they can’t grow or shrink. You can access their values by index or even destructure them into separate variables.

let tup: (i32, f64, u8) = (500, 6.4, 1);

Since tuple values are distinctly recognized by their position, you can access them in two ways: by destructuring into separate variables or by using the dot (.) operator with an index.

let tup: (i32, f64, char) = (42, 3.14, 'R');  

// Destructuring  
let (a, b, c) = tup;  
println!("b is {}", b);  

// Dot notation  
println!("First: {}, Second: {}", tup.0, tup.1);

Array Types

In Rust, an array is a compound type that stores multiple values of the same type in a fixed-size, ordered list. Unlike tuples (which can mix types), arrays are strictly homogeneous. Once declared, their size is fixed—they can’t grow or shrink.

Core properties of arrays:

  • Fixed size (length known at compile time).

  • Elements must all be of the same type.

  • Accessed by zero-based indexing (arr[0]).

  • Stored on the stack (making them very fast).

Common operations:

  • Access elements with an index (arr[2]).

  • Destructure like tuples.

  • Get the length with .len().

  • Iterate using loops (for element in arr).

      let arr: [i32; 4] = [10, 20, 30, 40];  
    
      // Access by index  
      println!("First element: {}", arr[0]);  
    
      // Length  
      println!("Length: {}", arr.len());  
    
      // Iteration  
      for val in arr.iter() {  
          println!("{}", val);  
      }  
    
      // Destructuring  
      let [a, b, c, d] = arr;  
      println!("b is {}", b);
    

Functions

In Rust, a function is defined with the fn keyword, and its structure looks much like a TypeScript function: a name, parameters, and an optional return type.

  • Parameters

    • Functions can take parameters of any type, just like in other languages. Some values (especially those stored on the heap) can introduce ownership complexities—but for now, you can think of parameters as working the same way as in TypeScript or JavaScript.
  • Return Values

    • Functions can return values of any type, with the return type explicitly declared after ->. If the function body is a simple one-liner, you can omit the return keyword and just leave the expression without a semicolon.

        fn add(x: i32, y: i32) -> i32 {
            x + y // no semicolon = return
        }
      

There’s a lot more to functions in Rust (like ownership, borrowing, and lifetimes), but we’ll keep things simple for now.

Control Flow

If Else

  • In Rust, if and else work much like in TypeScript—the logic is the same, but the syntax is a bit stricter. Rust doesn’t require parentheses around conditions, and it even allows you to use if expressions directly when assigning values with let.

      let number = 7;
    
      if number < 5 {
          println!("Less than five");
      } else {
          println!("Five or more");
      }
    
      // Using if as an expression
      let result = if number % 2 == 0 { "even" } else { "odd" };
      println!("Number is {}", result);
    

Loops

  • Repeating code with loop

    The loop keyword tells Rust to execute a block of code over and over again forever or until you explicitly tell it to stop.

fn main() {
    loop {
        println!("again!");
    }
}

In Rust, loops don’t differ much in syntax, but things get tricky with nested loops. To avoid confusion, you can assign a label to each loop. Labels let you explicitly control which loop to break or continue, making your code much clearer.

'outer: loop {
    println!("In outer loop");

    'inner: loop {
        println!("In inner loop");
        break 'outer; // exits the outer loop directly
    }
}

This feature comes in handy when multiple loops are stacked, so you can precisely decide which one to jump out of without losing your sanity.

Using While Loop

When a loop depends on a condition, Rust uses a while loop. It keeps running as long as the condition evaluates to true, making it the go-to choice when you don’t just want an endless loop.

let mut count = 0;

while count < 5 {
    println!("count = {}", count);
    count += 1;
}

Using For Loop

When you need to iterate over a range or a collection, Rust’s for loop is the cleanest option. It handles iteration safely and avoids off-by-one errors that are common in traditional loops.

// Looping through a range
for i in 0..5 {
    println!("i = {}", i);
}

// Looping through an array
let arr = [10, 20, 30];
for val in arr.iter() {
    println!("value = {}", val);
}

Ownership

If you’ve been wondering where Rust really starts testing your patience—this is it. Ownership is the feature that makes Rust both powerful and (at first) slightly headache-inducing. It’s that point where you’ll probably pause, reread the docs, and grab another cup of coffee. Think of it as Rust saying: “Fasten your seatbelt, turbulence ahead.” But don’t worry—once you get it, you’ll realize this is exactly what makes Rust worth learning.

Why Ownership Exists

In languages like TypeScript or Java, memory is cleaned up automatically by a Garbage Collector (GC)—a highly optimized piece of code that handles messy stuff like null pointers, dangling pointers, and memory leaks. The trade-off? You rarely think about memory management at all.

But in low-level languages like C, you manage memory yourself. That sounds powerful, but let’s be honest: writing bug-free, leak-free memory code in C is like walking a tightrope blindfolded—possible, but not fun.

Rust takes the middle ground. It gives you direct control of memory, but with rules that keep you from shooting yourself in the foot. No garbage collector, no hidden magic—just predictable, safe memory management.

Stack and Heaps

Basically there are two types of memory that are used by your program Stack and Heap memory.

Stack Memory

In Rust (and most languages), stack memory is managed in a very orderly fashion—think of it like a neat stack of trays in a cafeteria. Data is pushed in and popped out in a predictable LIFO (Last In, First Out) manner, which is why accessing and storing values here is blazing fast.

The stack is best suited for data that:

  • Has a fixed size (known at compile time).

  • Can be managed in this ordered, temporary structure.

Because the stack is relatively small and grows only in one direction, trying to store things with unpredictable or infinite size (like dynamically growing data) can quickly lead to the dreaded stack overflow. That’s why arrays of fixed size, scalar values, and simple structures are great candidates for the stack, but more flexible data (like Vec<T>) needs a different place.

fn main() {
    let x: i32 = 42; // stored directly on the stack
    println!("x is {}", x);
}

Here, x is stored directly on the stack. Rust knows at compile time that i32 is exactly 4 bytes, so it just pushes it onto the stack like a clean entry in a to-do list.

Heap Memory

That different place is the heap. Unlike the stack, the heap is designed for data whose size may not be known until runtime, or that needs to live longer than the stack frame where it was created. Allocation here is slower, since the system has to find free space in a large pool of memory, but it gives you flexibility.

fn main() {
    let s: String = String::from("Hello, Heap!"); 
    println!("{}", s);
}

Here’s what really happens:

  • The pointer, length, and capacity of the string live on the stack.

  • The actual string data "Hello, Heap!" is stored on the heap.

So the stack just says: “Hey, the real stuff is over there, at this memory address.”

String and Slices

When we declare a string in Rust there is generally two ways to define it…

1. String

The String type is the full, growable string. Under the hood, three things (the pointer, length, and capacity) are stored on the stack, while the actual text lives on the heap.

Because strings can grow or shrink at runtime, they need the heap’s flexibility. But this also means operations like cloning or moving are more expensive—since they involve heap memory.

fn main() {
    let mut s: String = String::from("Hello");
    s.push_str(", world!"); // modifies the heap data
    println!("{}", s);
}

2. &str (String Slice)

A slice is like a lightweight view into a string. Instead of owning the heap data, it just keeps a pointer and length that refer to part (or all) of an existing string. Slices are extremely efficient because they don’t copy data—they just borrow a piece of it.

fn main() {
    let s: String = String::from("Hello, world!");

    // Create a slice referencing part of the string
    let hello: &str = &s[0..5]; 
    let world: &str = &s[7..12]; 

    println!("Slice 1: {}", hello); 
    println!("Slice 2: {}", world);
}

Here, hello and world are just references to parts of s. No extra heap allocation is made.


Now that you have a clear idea of stack vs heap memory and how String is stored (with its pointer on the stack but actual data on the heap), we can connect this to Rust’s ownership model.

The ownership rules in Rust are most relevant for heap-allocated data—because that’s where things usually go wrong in other languages:

  • Memory leaks when heap data is never freed.

  • Dangling pointers when something tries to access memory that was already freed.

  • Read-write conflicts (data races) when multiple parts of a program try to mutate the same heap data at the same time.

Rust’s ownership system was designed to eliminate these problems at compile time, without needing a garbage collector.

Let’s imagine Heap data (like String) is a girl 👩, and the owner is her boyfriend 👦.

  • Every girl must have a boyfriend → Every heap value must have an owner.

  • She can have only one boyfriend at a time → A value can only have one owner.

  • If the boyfriend leaves, she’s gone too → When the owner goes out of scope, the value is dropped.

  • If another guy wants to date her → Ownership must be transferred (moved). No two owners at once.

fn main() {
    // Boyfriend owns the girl (heap data: String)
    let boyfriend1 = String::from("Alice"); // Alice is the girl

    // Transfer of ownership (new boyfriend)
    let boyfriend2 = boyfriend1; 

    // println!("{}", boyfriend1); ❌ Error! 
    // The first boyfriend lost ownership after breakup.

    println!("Now only boyfriend2 owns: {}", boyfriend2);
}

Explanation:

  • When boyfriend2 = boyfriend1 happens, the ownership of "Alice" (the girl on heap) moves from boyfriend1 to boyfriend2.

  • boyfriend1 can no longer access "Alice".

  • If we try to print boyfriend1, Rust gives a compile-time error, preventing double-dating (two owners for the same girl).

Now this is why Rust feels strict but safe: it enforces this “one owner rule” to avoid memory bugs.

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

Ignore the concept that you don’t understand and let’s discuss the owenership rule in the Rust. You see in the above code when a string is passed in the function direclty it takes it’s ownership by default and after the function call the variable goes out of scope.

When you pass a String into a function, Rust doesn’t give the function a “clone” of the girl (heap data). Instead, the whole boyfriend role (ownership) is transferred. The function is now her boyfriend. When the function ends (boyfriend dies), the girl (data) also dies.

Passing by Reference (Borrowing Instead of Breakup)

But Rust gives you a choice: instead of always moving ownership, you can just let the function borrow the girl.

That means:

  • The ownership stays with you (she’s still your girlfriend).

  • The function just gets temporary access (like hanging out with her).

You get to decide:

  • Immutable Borrow (&T) → read-only → many friends allowed

  • Mutable Borrow (&mut T) → read-and-write → one boyfriend only, exclusive hanky panky

And here’s the catch:

  • If she’s with friends (immutable refs), no hanky panky is allowed.

  • If she’s with one boyfriend (mutable ref), then no other friends or boyfriends can exist at that time.

Rust is like the strict parent making sure these rules are never broken.

Why is This Important?

In languages like C/C++, two guys can mess around with the same girl at once:

  • Write–Write conflict → both try hanky panky → chaos

  • Read–Write conflict → one is chatting with her while the other is doing hanky panky → corrupted results

Rust says:
“Only one boyfriend at a time, no cheating!”

1. Ownership Transfer (Breakup → New Boyfriend)

fn main() {
    let s = String::from("Alice"); // s owns Alice (girl)
    takes_ownership(s); // ownership moved into function

    // println!("{}", s); Error: s no longer owns Alice
}

fn takes_ownership(girl: String) {
    println!("Now function owns: {}", girl);
} // girl (Alice) drops here automatically

2. Many Friends, No Boyfriend (Immutable Borrow)

fn main() {
    let mut name = String::from("Alice");

    let bf = &mut name; // boyfriend time
    bf.push_str(" ❤️"); // hanky panky

    println!("Exclusive boyfriend moment: {}", bf);

    // let f = &name; Not allowed: can't have friends during hanky panky
}

Many friends, all good.

3. Exclusive Hanky Panky (Mutable Borrow)

fn main() {
    let mut name = String::from("Alice");

    let bf = &mut name; // boyfriend time
    bf.push_str("s*x"); // hanky panky

    println!("Exclusive boyfriend moment: {}", bf);

    // let f = &name; Not allowed: can't have friends during hanky panky
}

One boyfriend only. No friends allowed.

4. Borrowing Without Breakup

fn main() {
    let s = String::from("Alice");

    borrow_string(&s); // borrowing, not breakup

    println!("Still my girlfriend after borrowing: {}", s); // Still mine
}

fn borrow_string(girl: &String) {
    println!("Just borrowing Alice as a friend: {}", girl);
}

Traits & Generics

Traits

  • Think of traits as Rust’s version of interfaces (from TypeScript/Java).

  • They define a set of methods (behavior) that a type must implement.

  • Traits can also provide default method implementations, so not everything has to be re-written.

  • Structs, enums, or even primitive types can implement traits.

  • Traits can be used for polymorphism (writing code that works across multiple types).

// Define a trait
trait Greet {
    fn greet(&self); // method signature
}

// Implement trait for a struct
struct Person {
    name: String,
}

impl Greet for Person {
    fn greet(&self) {
        println!("Hello, my name is {}", self.name);
    }
}

fn main() {
    let p = Person { name: "Alice".to_string() };
    p.greet(); // Calls trait method
}

Core points about traits:

  • Traits can be derived automatically (Debug, Clone, PartialEq).

  • Traits can be generic bounds (restricting which types work in functions).

  • Traits can be implemented for existing types (extension behavior).

  • You can also have trait objects (&dyn Trait) for runtime polymorphism.

2. Generics

  • Generics is similar as it is in any other language like Jave/Typescript.

  • Generics let you write code that works with any type, without duplication.

  • Defined using <T>, <U>, etc.

  • Very common in collections (Vec<T>, Option<T>, Result<T, E>).

  • Often combined with trait bounds to restrict behavior.

// Generic function
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut max = list[0];
    for &item in list {
        if item > max {
            max = item;
        }
    }
    max
}

fn main() {
    let nums = vec![10, 20, 5, 30];
    println!("Largest number: {}", largest(&nums));

    let chars = vec!['a', 'z', 'k'];
    println!("Largest char: {}", largest(&chars));
}

3. Traits + Generics Together

Traits and Generics are usually paired to build reusable and safe abstractions.

trait Area {
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }
struct Square { side: f64 }

impl Area for Circle {
    fn area(&self) -> f64 {
        3.14 * self.radius * self.radius
    }
}

impl Area for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

// Generic function with trait bound
fn print_area<T: Area>(shape: T) {
    println!("Area: {}", shape.area());
}

fn main() {
    let c = Circle { radius: 5.0 };
    let s = Square { side: 4.0 };

    print_area(c);
    print_area(s);
}

Error Handling

1. Result<T, E> Basics

  • Result is everywhere in Rust’s standard library — file I/O, networking, parsing, etc.

  • Signature form:

enum Result<T, E> {
    Ok(T),
    Err(E),
}
  • Example: Opening a file with std::fs::File always returns Result:
use std::fs::File;

fn main() {
    let file_result = File::open("data.txt");

    match file_result {
        Ok(file) => println!("Opened file successfully: {:?}", file),
        Err(e) => println!("Could not open file: {}", e),
    }
}

This way, errors are part of the function signature — you can’t ignore them.

2. unwrap and expect

  • Use sparingly, mostly in quick prototyping or tests.

  • Panic = program crashes immediately and unwinds the stack.

  • expect is better than unwrap because you at least get context in the error message.

let file = File::open("data.txt").expect("Failed to open data.txt");
// If file doesn’t exist → "Failed to open data.txt: No such file or directory"

In production, rely on match or ? operator, not unwrap/expect.

3. Safe Error Handling with match

  • The match expression is extremely powerful.

  • It forces you to cover all possible cases, which prevents accidental unhandled errors.

      fn divide(a: i32, b: i32) -> Result<i32, String> {
          if b == 0 {
              Err("Division by zero!".to_string())
          } else {
              Ok(a / b)
          }
      }
    
      fn main() {
          let result = divide(10, 0);
    
          match result {
              Ok(val) => println!("Division result: {}", val),
              Err(msg) => println!("Error occurred: {}", msg),
          }
      }
    

Insights on match:

  1. Exhaustiveness → You must handle both Ok and Err. Compiler forces this.

  2. Destructuring → You can directly pull values out.

     match divide(20, 5) {
         Ok(v) if v > 2 => println!("Big value: {}", v), // match + guard
         Ok(v) => println!("Small value: {}", v),
         Err(e) => println!("Error: {}", e),
     }
    
  3. Pattern matching goes beyond Result: you can match Enums, Tuples, Structs, even ranges.

4. if let and while let (Shorthand for match)

  • if let lets you ignore unwanted branches when you only care about one case.

  • Example:

      if let Ok(v) = divide(9, 3) {
          println!("Quick success: {}", v);
      } // Err case ignored
    
    • while let is handy for loops over fallible operations (like reading from a file/stream):
    let mut stack = vec![1, 2, 3];

    while let Some(top) = stack.pop() {
        println!("Popped: {}", top);
    }

if let and while let cut down boilerplate, but match is safer if you want to guarantee all cases are covered.

5. The ? Operator (Error Propagation)

  • Works with any type implementing FromResidual (so both Result and Option).

  • Equivalent to writing a match that returns early on Err.

      use std::fs::File;
      use std::io::{self, Read};
    
      fn read_file() -> Result<String, io::Error> {
          let mut file = File::open("data.txt")?; // if Err → return Err immediately
          let mut contents = String::new();
          file.read_to_string(&mut contents)?;
          Ok(contents)
      }
    

    Cleaner, reduces noise in deeply nested code.

Lifetimes

Lifetimes in Rust are one of the most unique concepts you’ll encounter. They’re not something you’ll typically see in languages like Java, TypeScript, or even C++.

At their core, lifetimes are Rust’s way of making sure that references are always valid. They prevent dangling references (a reference to memory that has already been freed).

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = String::from("xyz");

    let result = longest(string1.as_str(), string2.as_str());
    println!("The longest string is {}", result);
}

If you paste this code into your IDE, you’ll get a lifetime-related error. Rust doesn’t know if the reference returned from longest will always be valid, because it depends on the arguments passed in.

Why does this happen?

You know that variables are stored in stack or heap, and their lifetimes depend on scope. Rust wants to guarantee that whenever you return a reference, the data it points to will still be valid.

In the function above, Rust cannot automatically prove that both x and y will live long enough for the returned reference to be safe.

Rust says:
“Wait… you’re giving me back either x or y. But how do I know both of them live long enough? What if one is destroyed too soon? I need proof.”

Lifetime annotations

To solve this, Rust lets you specify lifetime parameters. Lifetimes are written with a ' followed by a name, like 'a.

Here’s how we fix the code:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

What this means:

  • 'a is a lifetime parameter.

  • Both x and y must live at least as long as 'a.

  • The returned reference will also live at least as long as 'a.

Think of it like this

  • Lifetime = how long a reference is valid.

  • Rust wants to be 100% sure that whenever you use a reference, the original value is still alive.

  • If it’s not sure, you must tell it explicitly with a lifetime annotation like 'a.

Key Rules

  • You don’t control lifetimes. Values live as long as their scope.

  • You only write lifetimes to help the compiler understand.

  • In most simple cases, Rust figures it out for you (That’s why you rarely see lifetimes in basic code).

Collections and Iterators

Rust gives us a few powerful, built-in collections. The most common ones are:

  • Vector (Vec<T>) – ordered, growable list

  • HashMap (HashMap<K, V>) – key → value pairs

  • HashSet (HashSet<T>) – unique unordered values

Vector

Think of a vector as an expandable array.

  • Stores values in order.

  • You can push, pop, loop over it.

      fn main() {
          let mut v: Vec<i32> = Vec::new(); // empty vector
          v.push(10);
          v.push(20);
          v.push(30);
    
          println!("{:?}", v); // [10, 20, 30]
    
          // Access by index
          println!("{}", v[1]); // 20
    
          // Safe get (returns Option)
          if let Some(val) = v.get(2) {
              println!("Third element = {}", val);
          }
    
          // Loop
          for num in &v {
              println!("{}", num);
          }
      }
    

HashMap

HashMap is like a dictionary: keys map to values.

  • Keys must be unique.

  • Values can be anything.

      use std::collections::HashMap;
    
      fn main() {
          let mut scores = HashMap::new();
    
          scores.insert("Alice", 50);
          scores.insert("Bob", 70);
    
          println!("{:?}", scores); // {"Alice": 50, "Bob": 70}
    
          // Get a value
          if let Some(score) = scores.get("Bob") {
              println!("Bob's score = {}", score);
          }
    
          // Update or insert if missing
          scores.entry("Charlie").or_insert(30);
    
          // Loop
          for (name, score) in &scores {
              println!("{}: {}", name, score);
          }
      }
    

HashSet

HashSet is just a collection of unique values.

  • No duplicates allowed.

  • Useful for membership checks.

      use std::collections::HashSet;
    
      fn main() {
          let mut numbers = HashSet::new();
          numbers.insert(10);
          numbers.insert(20);
          numbers.insert(20); // ignored, already exists
    
          println!("{:?}", numbers); // {10, 20}
    
          // Check membership
          if numbers.contains(&10) {
              println!("Set has 10");
          }
    
          // Remove
          numbers.remove(&20);
      }
    

Iterators

Iterators let you process collections step by step without writing manual loops.

  • They are lazy – nothing happens until you call a method like collect, sum, or for_each.

      fn main() {
          let v = vec![1, 2, 3, 4, 5];
    
          // Basic loop
          for x in v.iter() {
              println!("{}", x);
          }
    
          // Map (transform each item)
          let doubled: Vec<_> = v.iter().map(|x| x * 2).collect();
          println!("{:?}", doubled); // [2, 4, 6, 8, 10]
    
          // Filter (keep only some items)
          let even: Vec<_> = v.iter().filter(|x| *x % 2 == 0).collect();
          println!("{:?}", even); // [2, 4]
      }
    

Concurrency in Rust

Concurrency means doing multiple tasks at the same time (or giving the illusion of it). Rust gives us tools to write concurrent programs without data races — something C/C++ struggle with.

1. Threads

A thread is like a lightweight worker that runs alongside your main program just like in Java.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..5 {
            println!("Hello from spawned thread: {}", i);
            thread::sleep(Duration::from_millis(500));
        }
    });

    for i in 1..5 {
        println!("Hello from main thread: {}", i);
        thread::sleep(Duration::from_millis(500));
    }

    handle.join().unwrap(); // wait for spawned thread to finish
}
  • thread::spawn starts a new thread.

  • join makes sure the thread finishes before the program exits.

2. Move Closures

By default, a thread’s closure borrows variables, but sometimes that’s unsafe. You can use move to transfer ownership to the thread.

use std::thread;

fn main() {
    let numbers = vec![1, 2, 3];

    let handle = thread::spawn(move || { //using a shortform like arrow functions in Javascript.
        println!("Numbers = {:?}", numbers);
    });

    handle.join().unwrap();
}

Here, the vector is moved into the thread, so the main thread can’t use it after. This prevents dangling references.

Async & Await in Rust

Rust also supports asynchronous concurrency, which is about handling lots of tasks without blocking threads. Instead of spawning thousands of OS threads, async uses an event loop.

use tokio::time::{sleep, Duration};

#[tokio::main] // it is a macro
async fn main() {
    let task1 = async {
        sleep(Duration::from_secs(1)).await;
        println!("Task 1 done");
    };

    let task2 = async {
        sleep(Duration::from_secs(2)).await;
        println!("Task 2 done");
    };

    tokio::join!(task1, task2);
}

3. Message Passing (Channels)

Instead of multiple threads poking at the same data, Rust encourages channels: one thread sends data, another receives it.

use std::sync::mpsc; // multi-producer, single-consumer
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec!["hello", "from", "thread"];
        for val in vals {
            tx.send(val).unwrap();
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}
  • tx = transmitter, rx = receiver.

  • Threads send messages instead of fighting over shared data.

Data Structures

1. Arrays & Slices

Arrays

  • Fixed-size collection of elements of the same type.

  • Size must be known at compile time.

  • Stored entirely on the stack.

  • Useful for small, static collections.

      fn main() {
          let arr: [i32; 3] = [1, 2, 3];
          println!("Length: {}", arr.len());
      }
    

Slices

  • A dynamically sized view into an array (or Vec).

  • Don’t own data → just borrow it.

  • Size is flexible but can’t be changed (not growable).

  • Useful when passing parts of an array to functions.

      fn main() {
          let arr = [10, 20, 30, 40];
          let slice: &[i32] = &arr[1..3];
          println!("Slice: {:?}", slice); // [20, 30]
      }
    

2. Tuples

  • Group multiple values of different types into one compound type.

  • Fixed in size.

  • Accessed via indexing (tup.0) or destructuring.

  • Commonly used for lightweight grouping or returning multiple values from a function.

      fn main() {
          let tup: (i32, f64, &str) = (42, 3.14, "hi");
          let (x, y, z) = tup;
          println!("({}, {}, {})", x, y, z);
      }
    

3. Structs

  • Custom, named data types to group related fields.

  • Similar to classes without inheritance.

  • Support methods via impl.

  • Types:

    • Classic (named fields).

    • Tuple structs (unnamed fields).

    • Unit structs (no fields, often markers).

        struct Person {
            name: String,
            age: u8,
        }
      
        impl Person {
            fn greet(&self) {
                println!("Hi, I'm {}.", self.name);
            }
        }
      
        fn main() {
            let p = Person { name: String::from("Alice"), age: 25 };
            p.greet();
        }
      

4. Enums

  • Define a type by enumerating possible variants.

  • Each variant can hold different types/data.

  • Often used with match for exhaustive handling.

  • Enums + match = safer alternatives to “switch” in other languages.

      enum Shape {
          Circle(f64),
          Rectangle(f64, f64),
          Square(f64),
      }
    
      fn area(shape: Shape) -> f64 {
          match shape {
              Shape::Circle(r) => 3.14 * r * r,
              Shape::Rectangle(w, h) => w * h,
              Shape::Square(s) => s * s,
          }
      }
    

5. Option<T>

  • Represents an optional value.

  • Used instead of null.

  • Variants:

    • Some(T) → value exists.

    • None → no value.

  • Forces you to handle both cases explicitly → safer code.

      fn get_even(nums: &[i32]) -> Option<i32> {
          for &n in nums {
              if n % 2 == 0 {
                  return Some(n);
              }
          }
          None
      }
    

6. Result<T, E>

  • Represents the outcome of an operation: success or failure.

  • Variants:

    • Ok(T) → success, with a value.

    • Err(E) → failure, with error info.

  • Foundation of Rust’s error handling.

  • Often combined with ? operator for propagation.

      fn divide(a: i32, b: i32) -> Result<i32, String> {
          if b == 0 {
              Err("Division by zero".to_string())
          } else {
              Ok(a / b)
          }
      }
    
      fn main() {
          let res = divide(10, 2);
          println!("{:?}", res); // Ok(5)
      }
    

Rust 101: Beginner’s Complete Guide

Part 1 of 1

A beginner-friendly Rust series covering everything from basics and ownership to error handling, traits, and concurrency. Learn step by step with examples and projects to become a confident Rustacean.