Rust 101
Prelude
This script was made for the 2023/2024 Analysis and Synthesis of Algorithms course at Instituto Superior Técnico.
Getting Started
To install rust, please, follow the official documentation.
You should, at least, have the rust compiler (i.e., rustc
) installed.
Managing Rust Projects with Cargo
Cargo Overview
cargo
is Rust’s official build tool and package manager:
- Simplifies project management, dependency handling, and building.
Usage examples:
\small
cargo new my_project # create a new project
cargo build # build project
cargo build --profile realease # builds optimized binary
cargo run # runs the project
cargo test # runs tests
Managing Rust Projects with Cargo
Benefits of Cargo
Dependency Management and Build Automation:
- Automatically fetches and manages project dependencies.
- Handles compilation, linking, and building release artifacts.
Project Structure:
- Enforces a conventional project structure for consistency.
rustc
: The Rust compiler.
- Can be used directly, but Cargo simplifies many tasks.
Control Flow Constructs: If-statement
Rust provides flexible control flow constructs, including if
statements, for
-loops and while
-loops.
- The
if
statement syntax is straightforward:
let x = 5;
if x > 0 {
println!("Positive!")
} else {
println!("Non-positive")
}
Control Flow Constructs: Loops
While loop
while condition {
// Code inside the loop
}
Example:
let mut n = 3;
while n > 0 {
println!("Countdown: {}", n);
n -= 1
}
Control Flow Constructs: Loops
For loop
for item in iterable {
// Code to run for each item
}
Example:
let ns = vec![1, 2, 3, 4, 5, 6];
for n in ns {
println!("Number: {}", n);
}
Ranges and Iterators
Rust has a powerful syntax to declare ranges over natural numbers:
- Write
0..n
to declare a range starting in $0$ to $n-1$, and - Write
0..=n
to declare a range starting in $0$ to $n$
Example:
for n in 1..5 {
// Code to run for each number in the range [1, 5[
}
for n in 1..=5 {
// Code to run for each number in the range [1, 5]
}
Ranges and Iterators
Inspired from functional languages Rust allows us to write concise code using iterators. For example:
- One can obtain the sum of a vector by simply writing:
let ns = vec![1, 2, 3, 4, 5];
let sum: i32 = ns.iter().sum();
- Or, the squares of the previous vector with:
let squared = ns.iter().map(|x| x * x).collect();
Note that, |x| x * x
represents a lambda/closure.
Closures
A lambda is an anonymous function used to express single operations or behaviours.
- In Rust, lambdas are called closures
- Closures are defined using the
|param| expr
syntax
In the previous example, |x| x * x
is a closure that takes a single parameter x
and returns the square of x
(i.e., x * x
). Typically, the use of closures is recommend in scenarios where a short, one-off function is needed, and there’s no need to define a separate function.
Pattern Matching
Rust also has a powerful pattern matching construct:
match value {
pattern1 => // Code for pattern1,
pattern2 if condition => // Code for pattern2,
_ => // Default code,
}
For example:
let result = match x {
1 => "One",
2 | 3 => "Two or Three",
x if x < 0 => "Negative",
_ => "Other",
};
Option and Result
pub enum Option<T> { pub enum Result<T, E> {
None, Ok(T),
Some(T), Err(E),
} }
Option and Result are algebraic data types (ADTs) in Rust, providing a concise and explicit approach to managing potential missing values or errors in functions, thus eliminating implicit exceptions. This forces developers to actively acknowledge and handle these scenarios, promoting robust error management and contributing to enhanced code clarity and safety.
Option and Result
Examples:
let maybe_value: Option<i32> = Some(42);
let result: Result<i32, &str> = Ok(42);
// Unwrap value, if None returns the default the type
let value = maybe_value.unwrap_or_default();
// Multiply a valid result by 2
let result2 = result.map(|x| x * 2);
In Rust, it is recommend to use pattern matching to handle these types appropriately.
Parsing Integers from Stdin
Reading Input
Use std::io::stdin()
to read input from the standard input:
let mut input = String::new();
std::io::stdin().read_line(&mut input)
.expect("Failed to read line");
Parsing Integer from Stdin
Parsing Integers
Convert the input to a string using parse
:
let parsed_num: Result<i32, _> = input.trim().parse();
match parsed_num {
Ok(number) => // Use the parsed integer,
Err(_) => // Handle parsing error,
}
Reliably Parsing Various Integers
Step 1
Create a helper function to parse integers from a string:
#[derive(Debug)]
struct ParserError(String);
fn usize(str: &str) -> Result<usize, ParserError> {
str.trim()
.parse::<usize>()
.map_err(|e| ParserError(format!("{}", e)))
}
Note that, we don’t handle the error directly, we simply propagate it to the client using a special error ParserError
.
Reliably Parsing Various Integers
Step 2
With a line from the stdin parse as many integers as you would like. Here, we assume integers are separated by a white space.
\scriptsize
fn pair() -> Result<(usize, usize), ParserError> {
let mut buffer = String::new();
std::io::stdin()
.read_line(&mut buffer)
.map_err(|e| ParserError(format!("{}", e)))?;
let mut nums = buffer.split(" ");
let fst = nums
.next()
.ok_or_else(|| ParserError(format!("fst")))
.and_then(|s| usize(s))?;
let snd = // same as above ...
Ok((fst, snd))
}
Error Propagation
Try Operator
std::io::stdin()
.read_line(&mut buffer)
.map_err(|e| ParserError(format!("{}", e)))?; // <-
Note the special use of the ?
operator in the previous code. The ?
operator in Rust streamlines error propagation. Used in functions returning Result
, it unwraps the value on Ok
or short-circuits to return ParserError
early. This simplifies error handling, reducing the need for explicit match
or unwrap
statements and contributing to concise, readable code.
Ownership in Rust
In Rust, each value has a variable that is its “owner”:
- Ownership of a value can be transferred from one variable to another
- The original variable can no longer be used
fn transfer_ownership(s: String) {
// s is the owner of a String
// Ownership transferred to this function
} // s goes out of scope, memory freed
let x = String::from("Hello");
// Ownership transferred to the function
transfer_ownership(x);
// println!("{}", x); // Error: x is no longer valid
Borrowing in Rust – Immutable Borrowing
Borrowing allows references to values without transferring ownership:
- References can be mutable or immutable
- Multiple references can borrow a value immutably:
- No changes can be made
Example:
fn immutable_borrow(s: &String) {
// s is an immutable reference to a String
}
let x = String::from("Hello");
immutable_borrow(&x); // Passing an immutable reference
Borrowing in Rust – Mutable Borrowing
Only one mutable reference is allowed at a time:
- Prevents data races
Example:
fn mutable_borrow(s: &mut String) {
// s is a mutable reference to a String
}
let mut x = String::from("Hello");
mutable_borrow(&mut x); // Passing a mutable reference
Heap Allocation in Rust
Rust allows dynamic allocation on the Heap. Ownership ensures memory safety without GC.
- The
Box
type is used for heap allocating - Boxes have a single owner
\small
fn heap_allocation() -> Box<Vec<i32>> {
// Allocating a Vec<i32> on the heap and returning a Box
Box::new(vec![1, 2, 3])
}
// Ownership transferred to the variable data
let data = heap_allocation();
Heap Allocation in Rust – Dropping and Cleaning
Ownership rules govern cleanup; no need for explicit deallocation:
- When a variable goes out of scope, its memory is automatically freed
{
let data = Box::new(String::from("Heap Allocated"));
} // Memory is freed here
Practical Example – Parsing and Storing a Graph in the Heap
Step 1 - Choose a Memory Layout
To first parse a directed graph, $G$, choose how you’re going to represent it on memory, for example, using a adjacency list:
struct Graph {
v: usize,
adj: Vec<Vec<usize>>,
rev_adj: Vec<Vec<usize>>,
}
In this struct
we store:
-
v
, the number of vertexes -
adj
, the adjacency list of the directed graph $G$ -
rev_adj
, the reverse adjacency list of the graph, denoted $G^T$
Practical Example – Parsing and Storing a Graph in the Heap
Step 2 - Parse the Graph from the Stdin
- Read the edges from stdin and store them in the adjacency list
- Return a reference to a heap allocated graph
\scriptsize
fn parse_graph() -> Result<Box<Graph>, ParserError> {
let (v, mut e) = pair()?;
let mut adj: Vec<Vec<usize>> = vec![vec![]; v];
let mut rev_adj: Vec<Vec<usize>> = vec![vec![]; v];
while e > 0 {
let (u, w) = pair()?;
assert!((u - 1) < v);
assert!((w - 1) < v);
adj[u - 1].push(w - 1);
rev_adj[w - 1].push(u - 1);
e = e - 1
}
Ok(Box::new(Graph { v, adj, rev_adj }))
}
Practical Example – Parsing and Storing a Graph in the Heap
Step 3 - Entry point
The lifetime of the graph is only within its owner scope:
fn main() -> Result<(), ParserError> {
// Parse the graph and obtain ownership
let graph = parse_graph()?;
// Perform operations with the graph
// ...
// Graph ownership goes out of scope, and
// memory is automatically freed
Ok(())
}
Tips: Rust’s Smart Referencing
Rust’s implicit reference coercion automatically converts certain types to references when needed, simplifying the use of owned values in contexts requiring references and eliminating explicit conversion syntax.
\scriptsize
fn process_graph(g: &Graph) {
// Automatically coerces Box<Graph> to &Graph when passed
}
fn main() -> Result<(), ParserError> {
// Parse the graph and box it on the heap
let boxed_graph: Box<Graph> = parse_graph()?;
// Pass the boxed graph to a function expecting a reference
process_graph(&boxed_graph); // Implicit reference coercion
Ok(())
}
Tips: Rust’s Smart Dereference
Rust’s smart dereference coercion simplifies the process of dereferencing, automatically converting certain types to references when needed. This eliminates the need for explicit dereferencing syntax.
\small
fn process_graph(g: &Graph) {
// Automatically coerces &Graph to Graph when
// dereferencing parameter v
println!("Number of vertices: {}", g.v);
}
Tips: Efficient Code with Asserts
Leveraging asserts at the top of a function can optimize code execution by enabling the Rust compiler to eliminate runtime bounds checks when iterating through a vector data
using indices.
\small
fn process(data: &Vec<i32>, size: usize) {
assert!(data.len() >= size);
for i in 0..size {
// Internal vector bounds checks can be
// optimized away because of the assert invariant
data[i] ...
}
}
Tips: Useful resources
Rust’s standard library: https://doc.rust-lang.org/std/
- Indispensable
Rust by example: https://doc.rust-lang.org/rust-by-example/
- Examples that illustrate various Rust concepts
The Rust Programming Language: https://doc.rust-lang.org/book/
Enjoy Reading This Article?
Here are some more articles you might like to read next: