DEV Community

Cover image for The Complete(sh) Rust Cheat Sheet
Moe Katib
Moe Katib

Posted on

The Complete(sh) Rust Cheat Sheet

This "Complete(sh) Rust Cheat Sheet" provides a comprehensive guide to the Rust programming language, encompassing all of its major features. The topics covered range from the very basics, such as syntax and basic concepts, to more complex aspects, like concurrency and error handling. The cheat sheet also delves into Rust's unique features, like ownership, borrowing, and lifetimes, as well as its powerful type system and robust macro system. For each topic, clear examples are provided to illuminate the explanations. It's an ideal resource for both beginners who are just getting started with Rust and more experienced developers who want a quick refresher on specific Rust concepts.

I've compiled this cheat sheet as a comprehensive guide to the Rust programming language, intending it to be a personal reference tool. However, the beauty of the Rust community is in shared learning and collaboration. So, if you spot something I've missed, an error, or if you have suggestions for improvement, please don't hesitate to share your feedback. Remember, nobody is infallible, and this resource is no exception—it's through your insights that we can continue to refine and perfect it. Happy Rustaceaning!

Basic Syntax & Concepts

  1. Hello World

    Here's the standard "Hello, world!" program in Rust.

    fn main() {
        println!("Hello, world!");
    }
    
  2. Variables and Mutability

    Variables are immutable by default in Rust. To make a variable mutable, use the mut keyword.

    let x = 5; // immutable variable
    let mut y = 5; // mutable variable
    y = 6; // this is okay
    
  3. Data Types

    Rust is a statically typed language, which means that it must know the types of all variables at compile time.

    let x: i32 = 5; // integer type
    let y: f64 = 3.14; // floating-point type
    let z: bool = true; // boolean type
    let s: &str = "Hello"; // string slice type
    
  4. Control Flow

    Rust's control flow keywords include if, else, while, for, and match.

    if x < y {
        println!("x is less than y");
    } else if x > y {
        println!("x is greater than y");
    } else {
        println!("x is equal to y");
    }
    
  5. Functions

    Functions in Rust are defined with the fn keyword.

    fn greet() {
        println!("Hello, world!");
    }
    
  6. Structs

    Structs are used to create complex data types in Rust.

    struct Point {
        x: i32,
        y: i32,
    }
    let p = Point { x: 0, y: 0 }; // instantiate a Point struct
    
  7. Enums

    Enums in Rust are types that can have several different variants.

    enum Direction {
        Up,
        Down,
        Left,
        Right,
    }
    let d = Direction::Up; // use a variant of the Direction enum
    
  8. Pattern Matching

    Rust has powerful pattern-matching capabilities, typically used with the match keyword.

    match d {
        Direction::Up => println!("We're heading up!"),
        Direction::Down => println!("We're going down!"),
        Direction::Left => println!("Turning left!"),
        Direction::Right => println!("Turning right!"),
    }
    
  9. Error Handling

    Rust uses the Result and Option types for error handling.

    let result: Result<i32, &str> = Ok(42); // a successful result
    let option: Option<i32> = Some(42); // an optional value
    

This is just a taste of Rust's syntax and concepts. The language has many more features to explore as you continue learning.

Variables & Data Types

Rust is a statically typed language, which means it must know the types of all variables at compile time. The compiler can usually infer what type we want to use based on the value and how we use it.

Variables

By default, variables in Rust are immutable, meaning their values can't be changed after they're declared. If you want a variable to be mutable, you can use the mut keyword.

Immutable variable:

let x = 5;
Enter fullscreen mode Exit fullscreen mode

Mutable variable:

let mut y = 5;
y = 6;  // This is allowed because y is mutable
Enter fullscreen mode Exit fullscreen mode

Data Types

Rust has several data types built into the language, which can be grouped into:

  • Scalar Types: Represent a single value. Examples are integers, floating-point numbers, Booleans, and characters.

  • Compound Types: Group multiple values into one type. Examples are tuples and arrays.

Scalar Types

Integer:

let a: i32 = 5;  // i32 is the type for a 32-bit integer
Enter fullscreen mode Exit fullscreen mode

Float:

let b: f64 = 3.14;  // f64 is the type for a 64-bit floating point number
Enter fullscreen mode Exit fullscreen mode

Boolean:

let c: bool = true;  // bool is the type for a boolean
Enter fullscreen mode Exit fullscreen mode

Character:

let d: char = 'R';  // char is the type for a character. Note that it's declared using single quotes
Enter fullscreen mode Exit fullscreen mode

Compound Types

Tuple:

let e: (i32, f64, char) = (500, 6.4, 'J');  // A tuple with three elements
Enter fullscreen mode Exit fullscreen mode

Array:

let f: [i32; 5] = [1, 2, 3, 4, 5];  // An array of i32s with 5 elements
Enter fullscreen mode Exit fullscreen mode

These are some of the most basic data types and variable declarations in Rust. As you continue learning, you'll encounter more complex types and learn how to create your own.

Advanced-Data Types

Structs

Structs, or structures, allow you to create custom data types. They are a way of creating complex types from simpler ones.

Defining a struct:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}
Enter fullscreen mode Exit fullscreen mode

Creating an instance of a struct:

let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};
Enter fullscreen mode Exit fullscreen mode

Enums

Enum, short for enumeration, is a type that represents data that is one of several possible variants. Each variant in the enum can optionally have data associated with it.

Defining an enum:

enum IpAddrKind {
    V4,
    V6,
}
Enter fullscreen mode Exit fullscreen mode

Creating an instance of an enum:

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
Enter fullscreen mode Exit fullscreen mode

Option

The Option enum is a special enum provided by Rust as part of its standard library. It's used when a value could be something or nothing.

let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;  // Note that we need to provide the type of None here
Enter fullscreen mode Exit fullscreen mode

Result

The Result enum is another special enum from the standard library, primarily used for error handling. It has two variants, Ok (for success) and Err (for error).

enum Result<T, E> {
    Ok(T),
    Err(E),
}
Enter fullscreen mode Exit fullscreen mode

These are some of the more advanced data types in Rust. Understanding these concepts will allow you to write more robust and flexible Rust programs.

Standard Collections

Collections are data structures that hold multiple values. Rust's standard library includes several versatile collections: Vec<T>, HashMap<K, V>, and HashSet<T>.

Vectors

Vector, or Vec<T>, is a resizable array type provided by Rust's standard library. It allows you to store more than one value in a single data structure that puts all the values next to each other in memory.

Creating a vector and adding elements to it:

let mut v: Vec<i32> = Vec::new();  // creates an empty vector of i32s
v.push(5);
v.push(6);
v.push(7);
v.push(8);
Enter fullscreen mode Exit fullscreen mode

HashMap

HashMap, or HashMap<K, V>, is a collection of key-value pairs, similar to a dictionary in other languages. It allows you to store data as a series of key-value pairs where each key must be unique.

Creating a HashMap and adding elements to it:

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
Enter fullscreen mode Exit fullscreen mode

HashSet

HashSet, or HashSet<T>, is a collection of unique elements. It's implemented as a hash table where the value of each key is a meaningless (), because the only value we care about is the key.

Creating a HashSet and adding elements to it:

use std::collections::HashSet;

let mut hs = HashSet::new();
hs.insert("a");
hs.insert("b");
Enter fullscreen mode Exit fullscreen mode

These are some of the main collection types in Rust. Each of them can be quite useful depending on what you're trying to achieve in your program.

BTreeMap

A BTreeMap is a map sorted by its keys. It allows you to get a range of entries on-demand, which is useful when you're interested in the smallest or largest key-value pair, or you want to find the largest or smallest key that is smaller or larger than a certain value.

use std::collections::BTreeMap;

let mut btree_map = BTreeMap::new();
btree_map.insert(3, "c");
btree_map.insert(2, "b");
btree_map.insert(1, "a");

for (key, value) in &btree_map {
    println!("{}: {}", key, value);
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the keys are sorted in ascending order when printed out, despite being inserted in a different order.

BTreeSet

The BTreeSet is essentially a BTreeMap where you just want to remember which keys you've seen and there's no meaningful value to associate with your keys. It's useful when you just want a set.

use std::collections::BTreeSet;

let mut btree_set = BTreeSet::new();
btree_set.insert("orange");
btree_set.insert("banana");
btree_set.insert("apple");

for fruit in &btree_set {
    println!("{}", fruit);
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the fruits are printed out in lexicographic order (i.e., alphabetical order), despite being inserted in a different order.

BinaryHeap

A BinaryHeap is a priority queue. It allows you to store a bunch of elements but only ever process the "biggest" or "most important" one at any given time. This structure is useful when you want a priority queue.

use std::collections::BinaryHeap;

let mut binary_heap = BinaryHeap::new();
binary_heap.push(1);
binary_heap.push(5);
binary_heap.push(2);

println!("{}", binary_heap.peek().unwrap());  // prints: 5
Enter fullscreen mode Exit fullscreen mode

In the example above, despite being inserted in a different order, the "peek" operation retrieves the largest number in the heap.

Control Flow

Rust provides several constructs to control the flow of execution in your program, including if, else, loop, while, for, and match.

if-else

The if keyword allows you to branch your code depending on conditions. else and else if can be used for alternative conditions.

let number = 7;

if number < 5 {
    println!("condition was true");
} else {
    println!("condition was false");
}
Enter fullscreen mode Exit fullscreen mode

loop

The loop keyword gives you an infinite loop. To stop the loop, you can use the break keyword.

let mut counter = 0;

loop {
    counter += 1;

    if counter == 10 {
        break;
    }
}
Enter fullscreen mode Exit fullscreen mode

while

The while keyword can be used to loop while a condition is true.

let mut number = 3;

while number != 0 {
    println!("{}!", number);

    number -= 1;
}
Enter fullscreen mode Exit fullscreen mode

for

The for keyword allows you to loop over elements of a collection.

let a = [10, 20, 30, 40, 50];

for element in a.iter() {
    println!("the value is: {}", element);
}
Enter fullscreen mode Exit fullscreen mode

match

The match keyword allows you to compare a value against a series of patterns and then execute code based on which pattern matches.

let value = 1;

match value {
    1 => println!("one"),
    2 => println!("two"),
    _ => println!("something else"),
}
Enter fullscreen mode Exit fullscreen mode

Each of these control flow constructs can be used to control the path of execution in your Rust programs, making them more flexible and dynamic.

Functions

A function is a named sequence of statements that takes a set of inputs, performs computations or actions, and optionally returns a value. The inputs to a function are called parameters, and the output it returns is called its return value.

Defining and Calling a Function

Functions are defined with the fn keyword. The general form of a function looks like this:

fn function_name(param1: Type1, param2: Type2, ...) -> ReturnType {
    // function body
}
Enter fullscreen mode Exit fullscreen mode

Here's an example of a simple function that takes two integers and returns their sum:

fn add_two_numbers(x: i32, y: i32) -> i32 {
    x + y  // no semicolon here, this is a return statement
}
Enter fullscreen mode Exit fullscreen mode

And here's how you would call this function:

let sum = add_two_numbers(5, 6);
println!("The sum is: {}", sum);
Enter fullscreen mode Exit fullscreen mode

Function Parameters

Parameters are a way to pass values into functions. The parameters are specified in the function definition, and when the function is called, these parameters will contain the values that are passed in.

Here's an example of a function with parameters:

fn print_sum(a: i32, b: i32) {
    let sum = a + b;
    println!("The sum of {} and {} is: {}", a, b, sum);
}
Enter fullscreen mode Exit fullscreen mode

Returning Values from Functions

Functions can return values. In Rust, the return value of a function is synonymous with the value of the final expression in the block of the body of a function. You can return early from a function by using the return keyword and specifying a value, but most functions return the last expression implicitly.

Here's a function that returns a boolean value:

fn is_even(num: i32) -> bool {
    num % 2 == 0
}
Enter fullscreen mode Exit fullscreen mode

In Rust, functions create a new scope for variables, which can lead to concepts such as shadowing and ownership, which are crucial aspects of Rust's system for managing memory.

Error Handling

Rust groups errors into two major categories: recoverable and unrecoverable errors. For a recoverable error, such as a file not found error, it’s reasonable to report the problem to the user and retry the operation. Unrecoverable errors are always symptoms of bugs, like trying to access a location beyond the end of an array.

Rust doesn’t have exceptions. Instead, it has the type Result<T, E> for recoverable errors and the panic! macro that stops execution when the program encounters an unrecoverable error.

Here's a basic example of using Result:

fn division(dividend: f64, divisor: f64) -> Result<f64, String> {
    if divisor == 0.0 {
        Err(String::from("Can't divide by zero"))
    } else {
        Ok(dividend / divisor)
    }
}
Enter fullscreen mode Exit fullscreen mode

And here's how you might handle the Result:

match division(4.0, 2.0) {
    Ok(result) => println!("The result is {}", result),
    Err(msg) => println!("Error: {}", msg),
}
Enter fullscreen mode Exit fullscreen mode

However, Rust provides the ? operator that can be used in functions that return Result, which makes error handling more straightforward:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = division(4.0, 0.0)?;
    println!("The result is {}", result);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

In the example above, if the division function returns Err, the error will be returned from the main function. If it returns Ok, the value inside the Ok will get assigned to result.

In addition to the standard error types provided by Rust, you can define your own error types.

enum MyError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> MyError {
        MyError::Io(err)
    }
}

impl From<std::num::ParseIntError> for MyError {
    fn from(err: std::num::ParseIntError) -> MyError {
        MyError::Parse(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Error Handling

For more advanced error handling, we can leverage the thiserror crate to simplify the process. The thiserror crate automates much of the process of creating custom error types and implementing the Error trait for them.

First, add thiserror to your Cargo.toml dependencies:

[dependencies]
thiserror = "1.0.40"
Enter fullscreen mode Exit fullscreen mode

Then, you can use #[derive(thiserror::Error)] to create your own custom error type:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),
    // Add other error variants here as needed
}
Enter fullscreen mode Exit fullscreen mode

With this error type, the Io and Parse variants are automatically created from std::io::Error and std::num::ParseIntError respectively thanks to the #[from] attribute. The #[error("...")] attribute specifies the error message.

You can use this custom error type in functions that return Result:

use std::fs::File;

fn read_file() -> Result<(), MyError> {
    let _file = File::open("non_existent_file.txt")?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

To ensure your code is future-proof against changes to the Error enum, Rust has the #[non_exhaustive] attribute. When this is added to your enum, it becomes non-exhaustive, and can therefore be extended with additional variants in future versions of the library:

#[non_exhaustive]
pub enum Error {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    // potentially more variants in the future
}
Enter fullscreen mode Exit fullscreen mode

Now, when matching on this Error enum outside of the crate it's defined in, Rust will enforce that a _ case is included:

match error {
    Error::Io(err) => println!("I/O error: {}", err),
    Error::Parse(err) => println!("Parse error: {}", err),
    _ => println!("Unknown error"),
}
Enter fullscreen mode Exit fullscreen mode

This advanced error handling approach provides a robust and flexible way to manage errors in Rust, particularly for library authors.

Enums and Pattern Matching

Enums, short for enumerations, allow you to define a type by enumerating its possible values. Here's a basic example of an enum:

enum Direction {
    North,
    South,
    East,
    West,
}
Enter fullscreen mode Exit fullscreen mode

Each variant of an enum is a type on its own. You can associate data with enum variants:

enum OptionalInt {
    Value(i32),
    Missing,
}
Enter fullscreen mode Exit fullscreen mode

Rust has a powerful feature called pattern matching which allows you to check for different cases with a clean syntax. Here's how you can use pattern matching with enums:

let direction = Direction::North;

match direction {
    Direction::North => println!("We are heading north!"),
    Direction::South => println!("We are heading south!"),
    Direction::East => println!("We are heading east!"),
    Direction::West => println!("We are heading west!"),
}
Enter fullscreen mode Exit fullscreen mode

Pattern matching in Rust is exhaustive: we must exhaust every last possibility in order for the code to be valid, otherwise the code will not compile. This feature is especially useful when dealing with enums as we are forced to handle all variants.

Rust also provides the if let construct as a more concise alternative to match where only one case is of interest:

let optional = OptionalInt::Value(5);

if let OptionalInt::Value(i) = optional {
    println!("Value is: {}", i);
} else {
    println!("Value is missing");
}
Enter fullscreen mode Exit fullscreen mode

In the example above, if let allows you to extract Value(i) from optional and print it, or print "Value is missing" if optional is OptionalInt::Missing.

Enum variants can also have methods with the impl keyword:

enum Message {
    Quit,
    ChangeColor(i32, i32, i32),
    Write(String),
}

impl Message {
    fn call(&self) {
        // method body
    }
}

let m = Message::Write(String::from("hello"));
m.call();
Enter fullscreen mode Exit fullscreen mode

In this example, we define a method named call on the Message enum and then use it for a Message::Write instance.

Enums in Rust are extremely versatile and with pattern matching, they offer a high degree of control flow in your program.

Non-exhaustive Enums and Structs

The #[non_exhaustive] attribute in Rust is a useful feature that ensures an enum or a struct is not exhaustively matched upon outside of the crate it is defined in. This is particularly useful for library authors who may need to add more variants or fields to an enum or struct in the future without breaking existing code.

#[non_exhaustive]
pub enum Error {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    // potentially more variants in the future
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the Error enum is non-exhaustive, which means it can be extended with additional variants in future versions of the library it's defined in. When matching on a non-exhaustive enum outside of its defining crate, you must include a _ case to handle potential future variants:

match error {
    Error::Io(err) => println!("I/O error: {}", err),
    Error::Parse(err) => println!("Parse error: {}", err),
    _ => println!("Unknown error"),
}
Enter fullscreen mode Exit fullscreen mode

If the _ case is not included, the code won't compile. This helps to ensure that your code is future-proof against changes to the Error enum.

The #[non_exhaustive] attribute can also be used with structs to prevent them from being destructured outside their defining crate, ensuring future fields can be added without breaking existing code.

This feature of Rust provides a degree of forward compatibility and makes it possible to extend enums and structs in libraries without causing breaking changes.

Ownership, Borrowing, and Lifetimes

Ownership is a key concept in Rust that ensures memory safety without the need for garbage collection. It revolves around three main rules:

  1. Each value in Rust has a variable that's called its owner.

  2. There can only be one owner at a time.

  3. When the owner goes out of scope, the value will be dropped.

let s1 = String::from("hello");  // s1 becomes the owner of the string.
let s2 = s1;  // s1's ownership is moved to s2.
// println!("{}", s1);  // This won't compile because s1 no longer owns the string.
Enter fullscreen mode Exit fullscreen mode

Borrowing is another key concept in Rust, which allows you to have multiple references to a value as long as they're not conflicting. There are two types of borrows: mutable and immutable.

let s = String::from("hello");
let r1 = &s;  // immutable borrow
let r2 = &s;  // another immutable borrow
// let r3 = &mut s;  // This won't compile because you can't have a mutable borrow while having an immutable one.
Enter fullscreen mode Exit fullscreen mode

Lifetimes are a way for the Rust compiler to ensure that references are always valid. It's an advanced concept in Rust and usually, the compiler can infer lifetimes in most cases. But sometimes, you might have to annotate lifetimes yourself:

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

In the example above, the function longest returns the longest of two string slices. The lifetime annotation 'a indicates that the returned reference will live at least as long as the shortest of the two input lifetimes.

Ownership, Borrowing, and Lifetimes are crucial to understanding how Rust manages memory and ensures safety. The Rust compiler enforces these rules at compile time, which allows for efficient and safe programs.

Generics

Generics are a way of creating functions or data types that have a broad applicability across different types. They're a fundamental tool for creating reusable code in Rust.

Here's an example of a function that uses generics:

fn largest<T: PartialOrd>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}
Enter fullscreen mode Exit fullscreen mode

In this example, T is the name of our generic data type. T: PartialOrd is a trait bound, it means that this function works for any type T that implements the PartialOrd trait (or in other words, types that can be ordered).

Generics can also be used in struct definitions:

struct Point<T> {
    x: T,
    y: T,
}
Enter fullscreen mode Exit fullscreen mode

In this example, Point is a struct that has two fields of type T. It means that a Point can have any type for x and y as long as they're the same type.

Generics are checked at compile time, so you have all the power of generics without any runtime cost. They are a powerful tool for writing flexible, reusable code without sacrificing performance.

Traits

Traits in Rust are a way to define shared behavior across types. You can think of them like interfaces in other languages.

Here's an example of defining a trait and implementing it:

trait Speak {
    fn speak(&self);
}

struct Dog;
struct Cat;

impl Speak for Dog {
    fn speak(&self) {
        println!("Woof!");
    }
}

impl Speak for Cat {
    fn speak(&self) {
        println!("Meow!");
    }
}
Enter fullscreen mode Exit fullscreen mode

In the example above, Speak is a trait that defines a method named speak. Dog and Cat are structs that implement the Speak trait. This means that we can call the speak method on instances of Dog and Cat.

Structs

Structs, or structures, are custom data types that let you name and package together multiple related values.

Here's how you can define a struct:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}
Enter fullscreen mode Exit fullscreen mode

And here's how you can create an instance of a struct:

let user = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername"),
    active: true,
    sign_in_count: 1,
};
Enter fullscreen mode Exit fullscreen mode

Structs are used to create complex data types in your program, and they're a fundamental part of any Rust program.

Modules and Namespaces

Modules in Rust allow you to organize your code into different namespaces. This is useful for readability and preventing naming conflicts.

Here's an example of how to define a module:

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
Enter fullscreen mode Exit fullscreen mode

In the example above, front_of_house is a module that contains another module hosting. add_to_waitlist is a function defined in the hosting module.

You can use the use keyword to bring a path into scope:

use crate::front_of_house::hosting;

fn main() {
    hosting::add_to_waitlist();
}
Enter fullscreen mode Exit fullscreen mode

In the example above, we use use to bring hosting into scope, which allows us to call add_to_waitlist without the front_of_house prefix.

Modules and namespaces are crucial for managing larger codebases and reusing code across different parts of your program.

Concurrency: Threads and Message Passing

Concurrency is a complex but important part of many programs, and Rust provides a number of ways to handle concurrent programming. One approach is to use threads with message passing for communication between them.

Here's how you can create a new thread:

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

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we use thread::spawn to create a new thread. The new thread prints a message and sleeps for a millisecond in a loop.

But how do we handle communication between threads? Rust's standard library provides channels for this purpose:

use std::thread;
use std::sync::mpsc;  // mpsc stands for multiple producer, single consumer.

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

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        // println!("val is {}", val);  // This won't compile because `val` has been moved.
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}
Enter fullscreen mode Exit fullscreen mode

In this example, mpsc::channel creates a new channel. The tx (transmitter) is moved into the new thread, and it sends the string "hi" down the channel. The rx (receiver) in the main thread receives the string and prints it.

Rust's threads and message-passing concurrency model enforce that all data sent between threads is thread-safe. The compile-time checks ensure that you don't have data races or other common concurrency problems, which can lead to safer and easier to reason about concurrent code.

Concurrency: Shared State Concurrency

In addition to message passing, Rust also allows for shared state concurrency through the use of Mutex (short for "mutual exclusion") and Arc (Atomic Reference Counter).

A Mutex provides mutual exclusion, meaning it ensures that only one thread can access some data at any given time. To access the data, a thread must first signal that it wants access by asking the mutex.

Arc, on the other hand, is a type of smart pointer that allows multiple owners of the same data and ensures that the data gets cleaned up when all references to it are out of scope.

Here's an example of how to use Mutex and Arc:

use std::sync::{Mutex, Arc};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Enter fullscreen mode Exit fullscreen mode

In this example, we create a counter inside an Arc<Mutex<T>> that can be safely shared and mutated across multiple threads. Each thread acquires a lock, increments the counter, and then releases the lock when the MutexGuard goes out of scope.

Using these tools, Rust can ensure safe concurrency through compile-time checks, helping to avoid common pitfalls associated with shared state concurrency like race conditions.

Error Handling: Panic vs. Expect vs. Unwrap

Error handling is crucial in any programming language, and Rust provides several tools for this:

  • panic!: This macro causes the program to terminate execution, unwinding and cleaning up the stack as it goes.
fn main() {
    panic!("crash and burn");
}
Enter fullscreen mode Exit fullscreen mode
  • unwrap: This method returns the value inside an Ok if the Result is Ok, and calls the panic! macro if the Result is Err.
let x: Result<u32, &str> = Err("emergency failure");
x.unwrap(); // This will call panic!
Enter fullscreen mode Exit fullscreen mode
  • expect: This method is similar to unwrap, but allows you to specify a panic message.
let x: Result<u32, &str> = Err("emergency failure");
x.expect("failed to get the value"); // This will call panic with the provided message.
Enter fullscreen mode Exit fullscreen mode

While unwrap and expect are straightforward, they should be used less frequently, as they can cause your program to abruptly terminate. In most cases, you should aim to handle errors gracefully using pattern matching and propagating errors when appropriate.

Testing

Testing is an essential part of software development, and Rust has first-class support for writing automated tests with the #[test] attribute:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the above code, #[test] marks the function as a test function, and assert_eq! is a macro that checks if the two arguments are equal, and panics if they're not.

FFI (Foreign Function Interface)

Rust provides a Foreign Function Interface (FFI) to allow Rust code to interact with code written in other languages. Here's an example of calling a C function from Rust:

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the extern "C" block defines an interface to the C abs function. It's marked unsafe because it's up to the programmer to ensure the correctness of the foreign code.

Macros

Macros in Rust are a way of defining reusable chunks of code. Macros look like functions, except they operate on the code tokens specified as their argument, rather than the values of those tokens.

Here's an example of a simple macro:

macro_rules! say_hello {
    () => (
        println!("Hello, world!");
    )
}

fn main() {
    say_hello!();
}
Enter fullscreen mode Exit fullscreen mode

In this example, say_hello! is a macro that prints "Hello, world!". Macros use a different syntax from regular Rust functions, and they're denoted by a ! after their name. They're a powerful tool for code reuse and metaprogramming in Rust.

Procedural Macros

Procedural macros in Rust are like functions: they take in code as an input, operate on that code, and produce code as an output. They are more flexible than declarative macros. Here's an example of a derive macro, which is a specific type of procedural macro:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(HelloWorld)]
pub fn hello_world_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);

    let gen = quote! {
        impl HelloWorld for #ast {
            fn hello_world() {
                println!("Hello, World! My name is {}", stringify!(#ast));
            }
        }
    };

    gen.into()
}
Enter fullscreen mode Exit fullscreen mode

In this example, we create a procedural macro that generates an implementation of a HelloWorld trait for the type it's given.

To use this macro, you would first add the crate to your dependencies in your Cargo.toml:

[dependencies]
HelloMacro = "0.1.0"
Enter fullscreen mode Exit fullscreen mode

Then, in your Rust code, you would import the macro and apply it to a struct or enum:

use HelloMacro::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
Enter fullscreen mode Exit fullscreen mode

In this example, the HelloMacro procedural macro generates a function called hello_macro for the Pancakes struct. When called, this function prints "Hello, Macro! My name is Pancakes".

Please note that creating a procedural macro involves more complexity than this example shows. Defining the HelloMacro procedural macro would require creating a separate crate of type proc-macro, and implementing a function that generates the desired code. The syn and quote crates are commonly used to parse and generate Rust code within procedural macros.

Rust's Built-In Traits

Rust has several built-in traits that have special meaning to the Rust compiler, such as Copy, Drop, Deref, and more.

For instance, the Copy trait signifies that a type's values can be duplicated simply by copying bits. If a type implements Copy, it can be duplicated without the original value being "moved". On the other hand, the Drop trait is used to specify what happens when a value of the type goes out of scope.

  1. Clone and Copy: The Clone trait is used for types that need to implement a method for creating a duplicate of an instance. If the duplication process is straightforward (i.e., just copying bits), the Copy trait can be used.

    #[derive(Clone, Copy)]
    struct Point {
        x: i32,
        y: i32,
    }
    
  2. Drop: This trait allows you to customize what happens when a value goes out of scope. This is particularly useful when your type is managing a resource (like memory or a file) and you need to clean up when you're done with it.

    struct Droppable {
        name: &'static str,
    }
    
    impl Drop for Droppable {
        fn drop(&mut self) {
            println!("{} is being dropped.", self.name);
        }
    }
    
  3. Deref and DerefMut: These traits are used for overloading dereference operators. Deref is used for overloading immutable dereference operators, while DerefMut is used for overloading mutable dereference operators.

    use std::ops::Deref;
    struct DerefExample<T> {
        value: T,
    }
    
    impl<T> Deref for DerefExample<T> {
        type Target = T;
        fn deref(&self) -> &T {
            &self.value
        }
    }
    
  4. PartialEq and Eq: These traits are used for comparing objects for equivalence. PartialEq allows partial comparison, while Eq requires full equivalence (i.e., it requires that every value must be equivalent to itself).

    #[derive(PartialEq, Eq)]
    struct EquatableExample {
        x: i32,
    }
    
  5. PartialOrd and Ord: These traits are used for comparing objects for ordering. PartialOrd allows partial comparison, while Ord requires a total ordering.

    #[derive(PartialOrd, Ord)]
    struct OrderableExample {
        x: i32,
    }
    
  6. AsRef and AsMut: These traits are used for cheap reference-to-reference conversions. AsRef is used for converting to an immutable reference, while AsMut is used for converting to a mutable reference.

    fn print_length<T: AsRef<str>>(s: T) {
        println!("{}", s.as_ref().len());
    }
    

These are just a few examples of the built-in traits available in Rust. There are many more, each serving a specific purpose. It's one of the ways Rust enables polymorphism.

Iterators and Closures

An iterator is a way of producing a sequence of values, usually in a loop. Here's an example:

let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();

for val in v1_iter {
    println!("Got: {}", val);
}
Enter fullscreen mode Exit fullscreen mode

A closure is an anonymous function that can capture its environment. Here's an example:

let x = 4;
let equal_to_x = |z| z == x;
let y = 4;
assert!(equal_to_x(y));
Enter fullscreen mode Exit fullscreen mode

Async Programming with Rust

Rust's async/.await syntax makes asynchronous programming in Rust much more ergonomic. Here's an example:

async fn hello_world() {
    println!("hello, world!");
}

fn main() {
    let future = hello_world(); // Nothing is printed
    futures::executor::block_on(future); // "hello, world!" is printed
}
Enter fullscreen mode Exit fullscreen mode

Pin and Unpin in Rust

Pin is a marker type that indicates that the value it wraps must not be moved out of it. This is useful for self-referential structs and other cases where it is not to be moved.

Unpin is an auto trait that indicates that the type it is implemented for can be safely moved out of.

  1. Pin: The Pin type is a wrapper which makes the value it wraps unmovable. This means that, once a value is pinned, it can no longer be moved elsewhere, and its memory address will not change. This can be useful when working with certain kinds of unsafe code that needs to have stable addresses, such as when building self-referential structs or when dealing with async programming.

    Here's an example of pinning a value:

    let mut x = 5;
    let mut y = Box::pin(x);
    
    let mut z = y.as_mut();
    *z = 6;
    
    assert_eq!(*y, 6);
    

    In the above example, y is a pinned Box containing the value 5. When we get a mutable reference to y with y.as_mut(), we can change the value in the Box, but we can't change y to point to something else. The value inside y is "pinned".

  2. Unpin: The Unpin trait is an "auto trait" (a trait automatically implemented by the Rust compiler) that is implemented for all types which do not have any pinned fields, essentially making it safe to move these types around.

    Here's an example of an Unpin type:

    struct MyStruct {
        field: i32,
    }
    

    In the above example, MyStruct is Unpin because all of its fields are Unpin. This means that it is safe to move MyStruct around in memory.

The Pin and Unpin traits are key parts of Rust's ability to safely handle memory and ensure that references to objects remain valid. They are used extensively in advanced Rust programming, such as when working with async/await or other forms of 'self-referential' structures.

Top comments (0)