Table of contents
- Basic Syntax & Concepts
- Variables & Data Types
- Control Flow
- Functions
- Error Handling
- Advanced Error Handling
- Enums and Pattern Matching
- Ownership, Borrowing, and Lifetimes
- Generics
- Traits
- Structs
- Modules and Namespaces
- Concurrency: Threads and Message Passing
- Concurrency: Shared State Concurrency
- Error Handling: Panic vs. Expect vs. Unwrap
- Testing
- FFI (Foreign Function Interface)
- Macros
- Procedural Macros
- Rust's Built-In Traits
- Iterators and Closures
- Async Programming with Rust
- Pin and Unpin in Rust
This "Complete 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
Hello World
Here's the standard "Hello, world!" program in Rust.
fn main() { println!("Hello, world!"); }
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
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
Control Flow
Rust's control flow keywords include
if
,else
,while
,for
, andmatch
.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"); }
Functions
Functions in Rust are defined with the
fn
keyword.fn greet() { println!("Hello, world!"); }
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
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
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!"), }
Error Handling
Rust uses the
Result
andOption
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;
Mutable variable:
let mut y = 5;
y = 6; // This is allowed because y is mutable
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
Float:
let b: f64 = 3.14; // f64 is the type for a 64-bit floating point number
Boolean:
let c: bool = true; // bool is the type for a boolean
Character:
let d: char = 'R'; // char is the type for a character. Note that it's declared using single quotes
Compound Types
Tuple:
let e: (i32, f64, char) = (500, 6.4, 'J'); // A tuple with three elements
Array:
let f: [i32; 5] = [1, 2, 3, 4, 5]; // An array of i32s with 5 elements
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,
}
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,
};
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,
}
Creating an instance of an enum:
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
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
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),
}
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);
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);
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");
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);
}
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);
}
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
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");
}
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;
}
}
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;
}
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);
}
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"),
}
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
}
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
}
And here's how you would call this function:
let sum = add_two_numbers(5, 6);
println!("The sum is: {}", sum);
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);
}
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
}
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)
}
}
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),
}
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(())
}
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)
}
}
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"
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
}
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(())
}
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
}
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"),
}
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,
}
Each variant of an enum is a type on its own. You can associate data with enum variants:
enum OptionalInt {
Value(i32),
Missing,
}
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!"),
}
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");
}
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();
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
}
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"),
}
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:
Each value in Rust has a variable that's called its owner.
There can only be one owner at a time.
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.
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.
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
}
}
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
}
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,
}
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!");
}
}
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,
}
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,
};
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() {}
}
}
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();
}
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));
}
}
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);
}
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());
}
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");
}
unwrap
: This method returns the value inside anOk
if theResult
isOk
, and calls thepanic!
macro if theResult
isErr
.
let x: Result<u32, &str> = Err("emergency failure");
x.unwrap(); // This will call panic!
expect
: This method is similar tounwrap
, 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.
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);
}
}
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));
}
}
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!();
}
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()
}
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"
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();
}
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.
Clone
andCopy
: TheClone
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), theCopy
trait can be used.#[derive(Clone, Copy)] struct Point { x: i32, y: i32, }
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); } }
Deref
andDerefMut
: These traits are used for overloading dereference operators.Deref
is used for overloading immutable dereference operators, whileDerefMut
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 } }
PartialEq
andEq
: These traits are used for comparing objects for equivalence.PartialEq
allows partial comparison, whileEq
requires full equivalence (i.e., it requires that every value must be equivalent to itself).#[derive(PartialEq, Eq)] struct EquatableExample { x: i32, }
PartialOrd
andOrd
: These traits are used for comparing objects for ordering.PartialOrd
allows partial comparison, whileOrd
requires a total ordering.#[derive(PartialOrd, Ord)] struct OrderableExample { x: i32, }
AsRef
andAsMut
: These traits are used for cheap reference-to-reference conversions.AsRef
is used for converting to an immutable reference, whileAsMut
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);
}
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));
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
}
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.
Pin
: ThePin
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 pinnedBox
containing the value5
. When we get a mutable reference toy
withy.as
_mut()
, we can change the value in theBox
, but we can't changey
to point to something else. The value insidey
is "pinned".Unpin
: TheUnpin
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
isUnpin
because all of its fields areUnpin
. This means that it is safe to moveMyStruct
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.