Learning from each other.
Introduction
In this article, I will explore Rust from the perspective of a Go developer.
I am using this article as my study notes while learning Rust.
While I’ll occasionally compare Rust with Go, I will not focus on the differences too much. I will highlight the features of Rust and share my thoughts on the language instead.
How To Read This Article
As in any profound topic, it’s virtually impossible to introduce basic concepts without referring to more advanced ones, and vice versa. So, this article feels more like a directed graph than a linear narrative.
To get the best value out of this article, I recommend reading four times:
- Read it from start to finish once, getting a general idea of the concepts.
- Read it again, focusing on the details and examples, using your favorite editor to write, run, and experiment with the code, changing it and trying to see what happens.
- And one final time, doing a deep dive and exploring the concepts in a non-linear way, jumping from one section to another, and trying to connect the dots.
- And one final time, but, this time doing the exploration with external supporting resources, such as the Rust documentation, Rust by Example, Rustlings, Reddit, Rust on YouTube, and the Programming Rust book.
The Tooling
Before we even get started with Rust, let’s install the toolchain.
I am on a Mac, so my instructions will be for macOS. Adjust them as needed for your operating system.
We’ll install rustup
, which is the recommended tool to install Rust and Cargo, the Rust package manager.
curl https://sh.rustup.rs -sSf | sh
The command above will download a script and start the installation.
By the end of the process you should see something like this:
Rust is installed now. Great!
To check that everything is working, run:
rustc --version
# Output will look like:
# 1.79.0 (129f3b996 2024-06-10)
cargo --version
# Output will look like:
# 1.79.0 (ffa9cf99a 2024-06-03)
Choosing an IDE
You can program Rust in your favorite text editor or IDE.
Some popular choices are:
I prefer RustRover because I am already familiar with JetBrains products and their keyboard shortcuts; however, you should choose the one that you feel most comfortable with.
Creating a New Project
To create a new Rust project, use the cargo
command:
# Go to a common workspace directory:
cd $WORKSPACE
# "hello-rust" is the name of the project:
cargo new hello-rust
# Switch to the project directory:
cd hello-rust
# Run the project:
cargo run
# Output will look like:
#
# Compiling hello-rust v0.1.0
# Finished `dev` profile [unoptimized + debuginfo] target(s)
# Running `target/debug/hello-rust`
#
# Hello, world!
Hello Rust
cargo new
creates a new project with the following structure:
├── Cargo.lock
├── Cargo.toml
├── src
│ └── main.rs
└── target
Here is the content of ./src/main.rs
:
fn main() {
println!("Hello, world!");
}
- The
fn
keyword is used to define a function. - The
main
function is the entry point of the program. - The
println!
macro is used to print text to the console. - Indentation is not significant in Rust, the style guide suggest using four spaces for indentation.
By merely looking at this “Hello, world” example, syntactically, Rust feels a bit like Go, with its own oddities and quirks.
When you run the above code using cargo run
, the output should be similar to the following:
println()!
is a macro that prints text to the console and returns the unit type ()
. This is similar to void
in other languages.
cargo run
# Output:
# Finished `dev` profile target(s) in 0.04s
# Running `target/debug/hello-rust`
# Hello, world!
Not bad for a first program, right?
Unit Type
The unit type ()
in Rust is a special type that represents an empty tuple. It’s often referred to as the “unit” type because it has only one value, which is also written as ()
. This type and its value are used in situations where you need to return or represent “nothing” in a type-safe way.
The unit type occupies no memory because it contains no data.
The unit time is commonly used in the following scenarios:
- As a return type for functions that perform an action but don’t produce a meaningful result (similar to
void
in C or C++). - As a placeholder in generic types.
- In
Result<T, E>
: when there’s no meaningful success value, you might useResult<(), E>
. - In expressions: The unit value
()
is the implicit return value of expressions if no other value is returned. - If a function doesn’t return a value, it implicitly returns
()
.
Here are some interesting use cases of the unit type:
struct Holder<T> {
value: Option<T>,
}
trait Callable {
type Output;
fn call(&self) -> Self::Output;
}
struct NoOp;
impl Callable for NoOp {
type Output = ();
fn call(&self) -> Self::Output {
// Do nothing and return ()
}
}
fn main() {
// Using () as a placeholder when we don't need to store any data
let empty_holder: Holder<()> = Holder { value: None };
// Using an actual type when we want to store data
let int_holder: Holder<i32> = Holder { value: Some(42) };
let no_op = NoOp;
let result = no_op.call();
assert_eq!(result, ());
}
We will see more about traits and generics later in this article where the above example will make more sense.
Rust Tooling
Rust comes with a set of tools that help you write, build, and test your code.
Here are some of the most commonly used tools:
rustc
: The Rust compiler.cargo
: The Rust package manager and build tool.rustup
: The Rust toolchain installer.rustdoc
: The Rust documentation generator.clippy
: A linter for Rust code.rustfmt
: A code formatter for Rust code.
There are more tools that you can install as needed using cargo install
.
rustfmt
And Code Formatting
Rust has something similar to gofmt
. It’s called rustfmt
.
Here are some key points about rustfmt
:
- Purpose: Like
gofmt
,rustfmt
is an automatic code formatter for Rust. It enforces a consistent coding style across Rust projects. - Installation: It comes bundled with Rust when you install via
rustup
. - Usage: You can run it from the command line:
rustfmt filename.rs
or format an entire project:cargo fmt
. - IDE Integration: Most Rust-aware IDEs and text editors can be configured to run rustfmt automatically on save.
- Configuration: Unlike
gofmt
, which is deliberately unconfigurable,rustfmt
allows some customization through arustfmt.toml
file in your project root. - Style Guide:
rustfmt
follows the Rust style guide by default, which helps maintain consistency across the Rust ecosystem. - CI Integration: Many projects run
rustfmt
in their CI pipelines to ensure all code follows the agreed-upon style.
Crates
- Crates are the smallest amount of code that the Rust compiler considers at a time.
- They can contain modules, and the modules may be split into different files.
Types of Crates
There are two types of crates:
- Binary Crates: Compile to an executable
- Library Crates: Don’t compile to an executable but are meant to be used in other programs
Using External Crates
Add dependencies to your Cargo.toml
file:
[dependencies]
rand = "0.8.5"
Then use the crate in your code:
use rand::Rng;
fn main() {
let random_number = rand::thread_rng().gen_range(1..101);
println!("Random number: {}", random_number);
}
Creating Your Own Crate
- Use
cargo new my_crate
for a binary crate - Use
cargo new my_crate --lib
for a library crate
Publishing a Crate
- Sign up for an account on
crates.io
- Set up your crate metadata in
Cargo.toml
- Use
cargo publish
to publish your crate
Cargo Workspaces
Cargo workspaces can manage multiple related crates.
Workspaces can also allow you to manage multiple related packages from one location.
Crate Features
Create features allow conditional compilation of crate functionalities.
Alternative Registries
- Cargo supports alternative registries other than
crates.io
. - You can set up your own private registry.
- You can even use Git repositories as dependencies.
- For local development, you can use path dependencies to reference local crates.
- There are also options for hosting your own private crate registry.
Here is how you would configure Cargo to user your private registry:
In .cargo/config.toml
:
[registries]
my-company = { index = "https://my-company-registry.com/index" }
In your project’s Cargo.toml
:
[dependencies]
private-crate = { version = "0.1.0", registry = "my-company" }
Or you can specify Git dependencies like this.
[dependencies]
private-crate = { git = "https://github.com/my-company/private-crate.git", branch = "dev" }
Here is how to create path dependencies:
[dependencies]
private-crate = { path = "../private-crate" }
What Is a Macro?
In Rust, a macro is a way of writing code that writes other code, which is also known as metaprogramming.
Macros provide a powerful and flexible tool to generate repetitive code, define domain-specific languages, or even alter the syntax of Rust itself.
I’ll go back to macros later, but now let’s focus our attention on more funner things.
About Stack Memory and Heap Memory
Before going any further, it’s essential to understand the difference between stack and heap memory models as they come up frequently in Rust programming, and they will immensely clarify your understanding of the important concepts such as ownership, borrowing, and lifetimes.
I’ll, again, get into the details of what ownership, borrowing and lifetimes are later in this article, but here’s a concise explanation of why stack and heap memory are important in Rust:
- Ownership and borrowing: Rust’s ownership rules are closely tied to how data is stored on the stack or heap. This affects how variables are passed, moved, or borrowed.
- Performance: Stack allocations are generally faster than heap allocations. Knowing where data is stored helps in writing more efficient code.
- Lifetimes: The stack’s LIFO (Last In, First Out) nature directly relates to Rust’s lifetime concept, which ensures memory safety.
- Memory safety: Rust’s guarantees about preventing null or dangling pointer dereferences, data races, and buffer overflows are implemented through strict control over stack and heap usage.
- Resource management: Understanding stack vs. heap helps in making informed decisions about how to structure data for optimal resource use.
- Zero-cost abstractions: Rust’s ability to provide high-level abstractions without runtime overhead is partly due to its sophisticated use of stack and heap.
Having said that, let’s dive into the details of stack and heap memory.
Stack Memory
- Stack memory is dynamically allocated and deallocated, but in a very specific way. Allocation and deallocation happen automatically as functions are called and return.
- Stack follows a strict last-in, first-out (LIFO) order.
- The size of stack allocations must be known at compile time.
Heap Memory
- Heap memory, can be allocated and deallocated at any time during program execution. This allows for more flexible memory management.
- The size can be determined at runtime.
- On the stack, the allocations and deallocations occur automatically with function calls and returns. Whereas on the heap, the allocations and deallocations can happen at any point in the program’s execution.
Size Flexibility
- Stack: The size of allocations must be known at compile time.
- Heap: The size can be determined at runtime.
Lifetime
- Stack: Memory is automatically reclaimed when a function returns.
- Heap: Memory persists until explicitly deallocated or the program ends.
Speed
- Stack: Generally faster, as it just involves moving a stack pointer.
- Heap: Typically slower, as it involves more complex memory management.
Fragmentation
- Stack: Less prone to fragmentation.
- Heap: More susceptible to memory fragmentation over time.
In Rust, Stack versus Heap distinction is particularly important because the language’s ownership system and borrowing rules are designed to make stack allocation safe and efficient while also providing tools for safe heap allocation when needed.
Fat Pointers
A fat pointer in Rust, as it relates to the String type, refers to a pointer that contains not just the memory address of the data, but also additional information about the data it points to. In the case of String
data type (that we’ll see later), the fat pointer contains three pieces of information:
- A pointer to the heap-allocated buffer containing the string data,
- The length of the string (number of bytes currently in use),
- And the capacity of the allocated buffer (total number of bytes allocated).
This structure allows Rust to efficiently manage String objects with several benefits:
- O(1) length checks: The length is stored directly in the fat pointer, so checking the length of a string is a constant-time operation.
- ** Efficient capacity management: By tracking both length and capacity, Rust can avoid unnecessary allocations when growing strings.
- Safe borrowing: The fat pointer enables Rust to enforce borrowing rules at compile-time, preventing data races and other memory safety issues. We will cover borrowing and ownership in more detail later in this article.
- Efficient slicing: Creating string slices (
&str
) is cheap because they can reuse the same heap-allocated buffer while adjusting the pointer and length.
It’s worth noting that while we commonly refer to this as a “fat pointer,” in Rust terminology, it’s more accurately described as a struct
containing a pointer
and two usize
values for length
and capacity
.
In a Nutshell
Stack
- Fast allocation and deallocation
- Fixed size, known at compile time
- LIFO (Last In, First Out) data structure
- Limited in size
- Automatically managed by the program
Heap
- Slower allocation and deallocation
- Dynamic size, can grow or shrink at runtime
- No particular order of allocation/deallocation
- Limited only by system memory
- Manually managed (in many languages, but Rust helps automate this)
Key Differences
- Performance: Stack allocation is generally faster because it’s just a matter of moving the stack pointer. Heap allocation requires more complex bookkeeping.
- Size flexibility: If you need a data structure that can change size, you’ll need to use the heap.
- Lifetime: Stack-allocated data lives only as long as the function it’s in. Heap-allocated data can live for as long as it’s needed, even beyond the function it was created in.
- Ownership: In Rust, heap-allocated data is subject to ownership rules, which help prevent memory leaks and data races. We will cover the concept of ownership later in this article.
Rust’s borrow checker and ownership system help manage heap allocations safely without a garbage collector, which is one of its key innovations.
Here’s a simple example to illustrate what is allocated where:
fn main() {
// Stack allocation
let x = 5; // Integer, known fixed size
let y = true; // Boolean, known fixed size
// Heap allocation
let s = String::from("hello"); // String, can grow or shrink
// Vector (similar to slice in Go) is heap-allocated
let v = vec![1, 2, 3, 4, 5];
}
In this example:
x
andy
are stack-allocated. Their size is known at compile time, and they’re automatically pushed onto and popped off the stack.s
andv
are heap-allocated. TheString
andVector
can grow or shrink, so they’re stored on the heap. What’s on the stack is actually a pointer to the heap data, along with length and capacity information.
Having overviewed pointers, and how memory management in the stack and heap, now let’s shift gears and move on to some basic Rust syntax.
Variables and Mutability
Variables are immutable by default. This means that once a variable is assigned a value, it cannot be changed.
fn main() {
let x = 5; // Immutable
println!("The value of x is: {}", x);
// x = 6; // This would cause an error
let mut y = 5; // Mutable
println!("The value of y is: {}", y);
y = 6; // This is allowed
println!("The value of y is now: {}", y);
}
let
is used to define variables (similar to:=
in Go).- Adding
mut
makes things mutable. - The
{}
inprintln!
is a placeholder for the value of the variable.
Returning From Functions
To define a function, we use the fn
keyword. Functions can return values either explicitly using the return
keyword or implicitly by omitting the semicolon at the end of the expression.
If you are omitting the semicolon, what you return should be the last expression in the function.
Here are some examples:
Returning From a Block
fn main() {
let x = {
let y = 5;
y + 1 // No semicolon here
};
println!("The value of x is: {}", x); // This will print 6
}
Implicit Return
// Implicit return
fn add(a: i32, b: i32) -> i32 {
a + b // Note: no semicolon
}
Explicit Return
// Explicit return
fn subtract(a: i32, b: i32) -> i32 {
return a - b;
}
Mixed Style
// Mixed style
fn abs(x: i32) -> i32 {
if x < 0 {
return -x; // Early return
}
x // Implicit return for the positive case
}
Implicit return in Rust allows for allows for concise return statements and is particularly useful in functional-style programming patterns.
Coding Conventions
In Rust, the convention is to use snake_case
for both functions and variables. This is different from Go, which uses camelCase
for functions and variables.
Another difference between Rust and Go is, by convention you indent using “four spaces” in Rust, whereas in Go you use Tab for indentation.
Here’s a quick overview of Rust naming conventions:
// Functions: snake_case
fn calculate_total()
//Variables: snake_case
let user_name = "Alice";
// Constants: SCREAMING_SNAKE_CASE
const MAX_CONNECTIONS: u32 = 100;
// Types (structs, enums, traits): PascalCase
struct UserProfile {}
// Modules: snake_case
mod network_utils;
// Macros: Usually snake_case!
println!(), vec![]
Here’s a small example demonstrating these conventions:
const MAX_USERS: u32 = 100;
struct UserAccount {
user_name: String,
email: String,
}
fn create_user(name: &str, email: &str) -> UserAccount {
UserAccount {
user_name: String::from(name),
email: String::from(email),
}
}
fn main() {
let new_user = create_user("alice", "alice@example.com");
println!("Created user: {}", new_user.user_name);
}
Following these conventions helps make Rust code more consistent and easier to read across different projects and the Rust ecosystem. They are not enforced by the compiler, but it’s considered good practice following them.
Data Types
Rust has a strong, static type system, which means that the type of every variable must be known at compile time.
fn main() {
// Integer types
let a: i32 = 5; // 32-bit signed integer
let b: u64 = 100; // 64-bit unsigned integer
// Floating-point types
let c: f64 = 3.14; // 64-bit float
// Boolean type
let d: bool = true;
// Character type
let e: char = 'z';
// String type
let f: String = String::from("Hello, Rust!");
println!("{}, {}, {}, {}, {}, {}", a, b, c, d, e, f);
}
- Type annotations are optional if Rust can infer the type.
- Integers can be signed (
i8
,i16
,i32
,i64
,i128
) or unsigned (u8
,u16
,u32
,u64
,u128
). - Floats come in
f32
andf64
. - Booleans are
true
orfalse
. - Chars are Unicode scalar values.
- Strings are UTF-8 encoded.
String
versus &str
Stack and Heap
If you haven’t paid attention to the Stack and Heap memory section near the beginning of this article, now is the time to revisit it.
String
and &str
are two different types used for working with text, each with its own characteristics and use cases.
Let’s see some of the key differences:
Allocation
String
The String
type itself is typically allocated on the stack.
However, the actual string data that the String
manages is allocated on the heap. This allows String
to be resizable, as heap memory can be dynamically allocated and deallocated.
&str
The &str
itself (the reference or “fat pointer”) is typically allocated on the stack.
The data it refers to can be anywhere in memory, depending on its origin:
- If it’s a string literal, the data is in the read-only data section of the program’s memory.
- If it’s a slice of a
String
, the data it refers to is on the heap (where theString
’s data lives). - If it’s a slice of a string in a static variable, the data is in the static memory section.
Here is a diagram to illustrate the difference:
Here is another diagram that illustrates a program’s memory layout:
Ownership
I will cover ownership later in this article, but for now, remember that:
String
is an owned type, meaning it owns its data and is responsible for allocating and deallocating memory.&str
is a borrowed type, specifically a string slice. It’s a reference to a sequence of UTF-8 bytes, typically part of another string or string-like data.
Mutability
String
is mutable by default. You can modify its contents, append to it, etc.&str
is immutable. You cannot modify the contents of a string slice.
Memory Allocation
String allocates memory on the heap. It can grow or shrink as needed. &str doesn’t allocate memory; it’s just a view into existing memory.
Flexibility
String
is more flexible. You can easily append, remove, or modify its contents.&str
is more rigid but more efficient for read-only operations.
Common Uses
- Use
String
when you need to own and modify string data. - Use
&str
for function parameters, string literals, or when you only need to read string data.
Conversion between String
and &str
- You can convert a
String
to a&str
with&
. - Converting
&str
toString
requires allocation (*e.g.,String::from()
or.to_string()
).
Likely, &str
will often be used for function parameters when you just need to read the string data, and String
when you need to own or modify the string.
fn print_string(s: &str) {
println!("{}", s);
}
fn main() {
let s1 = "Hello"; // &str
let s2 = String::from("World"); // String
print_string(s1);
print_string(&s2); // &String coerces to &str
}
Functions
Now, let’s look at how we define functions in Rust. We have already seen one function at least: main()
. Here are some more examples:
fn main() {
println!("Sum: {}", add(5, 3));
println!("Difference: {}", subtract(5, 3));
}
fn add(x: i32, y: i32) -> i32 {
x + y // Note: no semicolon here
}
fn subtract(x: i32, y: i32) -> i32 {
return x - y; // Using 'return' keyword
}
- Functions are declared using
fn
. - Parameters are typed.
- Return type is specified after
->
. - You can use an implicit return (last expression without semicolon) or the return keyword.
References
&
in Rust is similar to but not exactly the same as the address-of operator in languages like C or C++.
In Rust, &
is used to create a reference, which is similar to a pointer but with some important differences:
References in Rust:
- Are always valid (i.e., non-null)
- Have a lifetime
- Can be mutable or immutable
- Don’t require manual memory management
In comparison, the address-of operator in C/C++:
- Returns a raw memory address
- Can be
null
- Doesn’t have built-in lifetime tracking
- Doesn’t distinguish between mutable and immutable at the type level
Here’s a simple example to illustrate this concept:
fn main() {
let x = 5;
let y = &x; // y is a reference to x
println!("x is: {}", x);
println!("y is: {}", y); // This will print the value, not the address
println!("y points to: {}", *y); // Dereferencing, similar to C/C++
}
// Output:
// x is: 5
// y is: 5
// y points to: 5
- &x creates a reference to x, not just its memory address.
- When you print y, you get the value it points to, not the address.
- You can use *y to explicitly dereference, similar to C/C++. Which will again print the value
y
points to.
Rust also distinguishes between mutable and immutable references:
fn main() {
let mut a = 10;
let b = &a; // Immutable reference
let c = &mut a; // Mutable reference
// *b += 1; // This would be a compile-time error
*c += 1; // This is allowed
println!("a is now: {}", a);
}
In this example:
&a
creates an immutable reference&mut a
creates a mutable reference
Which also means, references are immutable by default.
Here are some more examples to illustrate the concept of references:
fn main() {
let a = 42;
// a = 43; // error: a is immutable.
let mut b = 42;
b = 43; // this is fine.
let c = &a; // immutable reference to a
// c = &b; // not allowed; c is immutable.
// mutable variable holding immutable reference:
let mut c = &a;
c = &b; // This is fine.
let d = &a;
// d = d + 1; // not allowed: &a is immutable.
let mut z = 42;
let e = &mut z;
*e = *e + 1;
println!("{}", e);
}
Borrow Rules
The Rust borrow checker enforces rules about these references:
- You can have any number of immutable references to a value
- OR you can have exactly one mutable reference
- BUT you can’t have both at the same time
This system allows Rust to prevent data races at compile time, which is a key feature of the language.
Hold onto these rules because they will be important. We will cover them later in more detail with examples.
Aren’t References Just Pointers?
In Rust, we say “reference to” rather than “pointer to” (unlike Go, or C) for several reasons:
- Safety: References in Rust are always valid. They can never be
null
and are guaranteed to point to valid memory. This is unlike raw pointers in languages like C or C++, which can be null or dangling. - Borrowing semantics: References in Rust come with built-in rules about how they can be used, enforced by the borrow checker. These rules prevent data races and ensure memory safety.
- Abstraction level: References operate at a higher level of abstraction than raw pointers. They’re designed to be safe and ergonomic to use, hiding some of the low-level details that pointers expose.
- Lifetime association: References in Rust have an associated lifetime, which the compiler uses to ensure they don’t outlive the data they refer to.
- Semantic meaning: The term “reference” implies a temporary borrowing of data, which aligns well with Rust’s ownership model. You’re “referring” to data owned by someone else, not taking possession of it.
- Distinction from raw pointers: Rust does have raw pointers (
*const T
and*mut T
), which are more similar to pointers in C. By using different terminology, Rust makes a clear distinction between its safe references and these unsafe raw pointers.
It’s worth noting that despite the different terminology, Rust references are typically implemented as pointers under the hood. The difference is in the guarantees and rules that Rust provides around their use.
What Kinds of Return to Use Where
- For short, single-expression functions, implicit return is often preferred as it’s more concise.
- For longer functions or those with complex logic, explicit returns can sometimes be clearer, especially for early returns.
- The Rust style guide doesn’t strictly mandate one over the other, but it does encourage consistent style within a codebase.
- Many Rustaceans prefer implicit returns where possible, as it aligns well with Rust’s expression-based nature.
- The
return
keyword is always necessary for early returns (i.e., returning before the end of the function body). - For very short functions, you might see the body written on the same line as the function signature:
fn square(x: i32) -> i32 { x * x }
The key is to prioritize readability and consistency. Choose the style that makes your code clearest in each specific context.
Dereferencing in Rust
Rust handles dereferencing differently (and more ergonomically) from C.
In C here is how you would dereference:
x = 5;
int* ptr = &x;
printf("Address: %p\n", (void*)ptr); // Prints hexadecimal address
printf("Value: %d\n", *ptr); // Prints 5
Whereas in Rust here is how it works:
fn main() {
let x = 5;
let ref_x = &x;
println!("ref_x: {:p}", ref_x); // Prints hexadecimal address
println!("Value: {}", ref_x); // Prints 5
println!("Also value: {}", *ref_x); // Also prints 5
}
- In Rust, when you print a reference with
{}
, it automatically dereferences and prints the value. - To print the address, you need to use the
{:p}
format specifier.
In Rust, you can use *ref_x
to explicitly dereference, similar to C. However, in many contexts, Rust will automatically dereference for you.
Also, it’s worth to mention once more that Rust references are always valid, so you don’t risk undefined behavior by dereferencing a null pointer.
Format Specifiers
Rust uses a powerful formatting system that allows for flexible and precise control over how values are displayed. Here’s an overview of the common format specifiers:
{}
: Default formatter- Used for general purpose formatting
- Automatically chooses an appropriate format based on the type
{:?}
: Debug formatter- Outputs a debug representation of a value
- Useful for debugging and development
{:#?}
: Pretty print debug formatter- Similar to
{:?}
, but with more readable, multi-line output
- Similar to
{:b}
: Binary formatter- Outputs integers in binary format
{:o}
: Octal formatter- Outputs integers in octal format
{:x}
: Lowercase hexadecimal formatter- Outputs integers in lowercase hexadecimal format
{:X}
: Uppercase hexadecimal formatter- Outputs integers in uppercase hexadecimal format
{:e}
: Lowercase exponential notation- Formats floating-point numbers in scientific notation with a lowercase “e”.
{:E}
: Uppercase exponential notation- Formats floating-point numbers in scientific notation with an uppercase “E”.
{:.}
: Precision specifier- Controls the number of decimal places for floating-point numbers. For example,
{:.2}
for two decimal places
- Controls the number of decimal places for floating-point numbers. For example,
{:width$}
: Width specifier- Sets the minimum width of the output. Pads with spaces if the value is shorter than the specified width
{:0width$}
: Zero-padded width specifier- Similar to
width$
, but pads with zeros instead of spaces
- Similar to
{:<}
,{:^}
,{:>}
: Alignment specifiers- Left-align, center-align, and right-align respectively
{:+}
: Sign specifier- Always prints the sign (
+
or-
) for numeric types
- Always prints the sign (
Here is an example code that demonstrates these:
fn main() {
let num = 42;
let pi = 3.14159;
let name = "Alice";
// Default formatter
println!("Default: {}", num);
// Debug formatter
println!("Debug: {:?}", num);
// Pretty print debug formatter
let complex_data = vec![1, 2, 3];
println!("Pretty print debug: {:#?}", complex_data);
// Binary, octal, and hexadecimal formatters
println!("Binary: {:b}", num);
println!("Octal: {:o}", num);
println!("Lowercase hex: {:x}", num);
println!("Uppercase hex: {:X}", num);
// Exponential notation
println!("Lowercase exponential: {:e}", pi);
println!("Uppercase exponential: {:E}", pi);
// Precision specifier
println!("Pi with 2 decimal places: {:.2}", pi);
// Width specifier
println!("Width of 10: {:10}", num);
// Zero-padded width specifier
println!("Zero-padded width of 5: {:05}", num);
// Alignment specifiers
println!("Left-aligned: {:<10}", name);
println!("Center-aligned: {:^10}", name);
println!("Right-aligned: {:>10}", name);
// Sign specifier
println!("Always show sign: {:+}", num);
// Combining specifiers
println!("Combined: {:+10.2}", pi);
}
Method Calls on References
In Rust, you can call methods on references without explicit dereferencing:
let s = String::from("hello");
let len = (&s).len(); // Works, but parentheses are unnecessary
let len = s.len(); // This is the idiomatic way
Here is a more intricate example:
let s = String::from("hello");
let len = &s.len(); // Works, but & is unnecessary
let len = (*s).len(); // Works, but unusual
let len = (&s).len(); // Works, but & is unnecessary
let len = s.len(); // This is the idiomatic way
Let’s break down what’s happening here:
&s.len()
: This takes a reference to the result of s.len(). It’s unnecessary but harmless. In Rust, when you call a method on a value, you don’t need to explicitly take a reference. The compiler automatically borrows a reference when calling methods.(*s).len()
: Although it looks (and feels) invalid, this is actually valid Rust code.*s
would typically mean “dereference s”, butString
implements the Dereftrait
(we will se more about traits later). When you use*
on a type that implementsDeref
, it calls thederef
method, which forString
returns a&str
. So(*s).len()
is equivalent tos.deref().len()
, which works but is unnecessarily complex.(&s).len()
: This takes a reference tos
and then callslen()
. It works, but the&
(*and the parentheses around&s
) is unnecessary.s.len()
: This is the idiomatic way.
In all cases, we are ultimately calling the len()
method on either the String
itself or a &str
derived from it.
The Deref
trait and Rust’s automatic referencing/dereferencing make all these forms work, even though some are more idiomatic than others.
Automatic Referencing and Dereferencing
Rust has a feature called “deref coercion” which automatically dereferences as needed in many contexts.
This behavior in Rust is designed to make working with references more ergonomic and less error-prone compared to raw pointers in C, while still allowing low-level control when needed.
In Rust, references are designed to behave much like regular variables in many contexts, with the language doing a lot of the “dirty work” behind the scenes.
This design choice has several benefits:
- Ergonomics: It makes code more readable and intuitive to write. You can often work with references as if they were the values themselves.
- Safety: By handling dereferencing automatically in many cases, Rust reduces the chance of errors that can occur with manual pointer manipulation.
- Zero-cost abstractions: Despite this high-level behavior, Rust compiles references down to efficient machine code, typically equivalent to raw pointers.
- Consistency: This behavior is part of a broader pattern in Rust where the language tries to do the “obvious” thing automatically, reducing boilerplate code.
Some examples of how Rust handles references “behind the scenes”:
- Automatic dereferencing for method calls.
- Deref coercion, which can convert between different levels of indirection.
- Borrow checking, which ensures references are used safely without runtime overhead.
- Lifetime elision, where the compiler often infers lifetimes without explicit annotation.
We will see “borrow checking” and “lifetime elision” later in this article.
These approaches allow Rust to provide the power and efficiency of low-level programming with the safety and ergonomics more commonly associated with high-level languages. It’s a key part of Rust’s goal to empower developers to write safe, concurrent, and performant code without sacrificing control over low-level details.
Borrow Checking
Borrow checking is Rust’s system for ensuring memory safety and preventing data races at compile time. It’s based on a set of rules about how references can be created and used.
Here are some key concepts that relate to borrow checking:
- Ownership: Every value in Rust has an owner (i.e., a variable).
- Borrowing: References allow you to refer to a value without taking ownership.
- Lifetimes: The compiler tracks how long references are valid.
You can also explicitly define lifetimes for variables too, but it’s better to dive that topic later in this article.
Here are some basic rules of borrowing:
At any given time, you can have either:
- One mutable reference (
&mut T
) - Any number of immutable references (
&T
) - References must always be valid (no dangling references).
Here’s an example:
fn main() {
let mut x = 5;
let y = &x; // Immutable borrow
let z = &x; // Another immutable borrow - this is fine
// We are using two immutable borrows here:
println!("{} {}", y, z);
// We stopped using y and z here, so we can create a mutable borrow.
// Remember that you cannot have both mutable and immutable borrows at the
// same time.
let w = &mut x; // Mutable borrow
*w += 1;
// println!("{}", y); // Error: can't use y here as x is mutably borrowed.
println!("{}", x); // This is okay - mutable borrow has ended
}
Non-Lexical Lifetimes (NLL)
When verifying borrow rules, the Rust borrow checker first considers lexical scope, but it goes beyond that with a more sophisticated analysis.
Let’s see how this works:
Lexical analysis: The borrow checker looks at lexical scope as a starting point. This is why you can have multiple borrows that don’t overlap in their lexical scopes.
Non-lexical lifetimes (NLL): However, Rust’s borrow checker uses a more advanced system called “non-lexical lifetimes” (NLL). This system was introduced to make the borrow checker smarter and more flexible.
Here’s how NLL works differently from pure lexical analysis:
- Flow-sensitive analysis: The borrow checker analyzes the actual control flow of the program, not just the lexical structure. It tracks where variables are actually used, not just where they’re in scope.
- Finer-grained lifetimes: NLL allows the compiler to end a borrow’s lifetime as soon as it’s no longer needed, even if it’s still in lexical scope.
- Multiple lifetime regions: The borrow checker can now reason about multiple lifetime regions within a single lexical scope.
Here’s an example:
fn main() {
let mut x = 5;
// While y (an immutable reference) is active,
// x cannot be borrowed or mutated.
//
// While y is active, x is effectively frozen.
//
// * The borrowed value (x in this case) cannot be mutated directly.
// * No mutable borrows of x can be created.
// * But additional immutable borrows are allowed.
let y = &x;
// x = 6; // This would fail because y is still active.
println!("{}", y); // This is fine.
// y is not used anymore. x can be mutated now.
x = 6; // This would fail with lexical analysis,
// but works with NLL.
println!("{}", x);
}
With purely lexical analysis, this code wouldn’t compile because y
is still in scope when we try to mutate x
.
However, with NLL, the compiler recognizes that y
isn’t used after the first println!
, so it’s safe to mutate x.
In essence, Rust’s borrow checker is more sophisticated than just lexical analysis. It uses lexical scope as a starting point but then applies more advanced techniques to provide a more accurate and flexible analysis of lifetimes and borrows.
Why Do We Need Borrow Rules?
But why do we need those rules. Let’s see some of the reasons:
- Data Races: In a multi-threaded context, if one thread could mutate data while another thread is reading it, this could lead to data races. Data races occur when multiple threads access the same memory location concurrently, and at least one of the accesses is a write. This can result in unpredictable behavior and is a common source of bugs in concurrent systems.
- Iterator Invalidation: If you’re iterating over a collection and the underlying data is modified during iteration, it could invalidate the iterator, potentially causing crashes or undefined behavior.
- Unexpected Value Changes: If code is reading a value through an immutable reference, it generally assumes that value won’t change. Allowing mutation through another path would violate this assumption, leading to logical errors in the program.
- Violation of Aliasing XOR Mutability: Rust’s type system is built on the principle that you can have either multiple readers (aliasing) or a single writer (mutability) at any given time, but not both. Violating this principle would undermine many of Rust’s safety guarantees.
- Breaking Optimizations: Compilers often make optimizations based on the assumption that immutably borrowed data doesn’t change. Allowing mutation through other means would break these optimizations.
- Memory Safety Issues: In more complex scenarios, this could lead to use-after-free bugs, dangling pointers, or other memory safety issues that Rust is specifically designed to prevent.
- Borrow checking prevents data races at compile time.
- Borrow checking eliminates entire classes of bugs (null/dangling pointer dereferences, use after free, etc.).
- Borrow checking enables fearless concurrency.
- No runtime overhead - all checks are done at compile time.
Other Benefits of Borrow Checking
The borrow checker also prevents common errors:
Use after free:
fn main() {
let y: &i32;
{
let x = 5;
y = &x; // Error: `x` does not live long enough
}
println!("{}", y);
}
Double free:
fn main() {
let s = String::from("hello"); // s owns the string
let t = s; // Ownership moved to t
println!("{}", s); // Error: use of moved value
// Note that at any given time,
// a value can have only one owner.
}
Ownership and Borrowing
There’s a significant difference between ownership and borrowing in Rust.
Let’s break this down:
Ownership
- In Rust, each value has a variable that is its “owner”.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped (freed).
Borrowing
Borrowing allows you to refer to some value without taking ownership of it.
There are two types of borrows: shared (
&T
) and mutable (&mut T
).You can have multiple shared borrows or one mutable borrow at a time, but not both simultaneously.
Here’s an example to illustrate the difference:
fn main() {
let s = String::from("hello"); // s owns the string
let len = calculate_length(&s); // s is borrowed here
println!("The length of '{}' is {}.", s, len);
// s is still valid here because it was only borrowed, not moved
}
fn calculate_length(s: &String) -> usize { // s is a borrowed reference
s.len()
}
In this example:
- s owns the
String
. - We pass a reference
&s
tocalculate_length
. This is borrowing:calculate_length
can use theString
, but doesn’t own it. - After
calculate_length
returns,s
is still valid and owned by the main function.
What if calculate_length
tried to take ownership of s
? Let’s see:
fn main() {
let s = String::from("hello");
let len = calculate_length(s);
// s is no longer valid here because ownership was transferred
// println("{}", s); // Error: value borrowed here after move.
println!("The length was: {}", len);
}
fn calculate_length(s: String) -> usize {
s.len()
}
If you want to use a value after it is moved to another function, you will get an error. This is because Rust prevents you from using a value after it has been moved to another owner.
Borrowing is powerful because it allows you to use data without transferring ownership, which means you can use it in multiple places without cloning or moving the data.
The key differences are:
- Ownership involves responsibility for cleaning up the data.
- Borrowed data is temporary and the borrower is not responsible for cleaning it up.
- Owned data can be modified freely (if it’s mutable), while borrowed data has restrictions (shared borrows can’t modify, only one mutable borrow at a time).
Here is another example:
fn main() {
let mut x = 42;
let f = &mut x;
// f is not used here, so we can create a mutable borrow.
let g = &mut x;
println!("{}", g);
}
Compare the code above with this one:
fn main() {
let mut x = 42;
let f = &mut x;
// f is used below ([1]),
// so we can't create a mutable borrow here.
// let g = &mut x; // This is an error.
println!("{}", f); // [1]
}
Yet another example:
func main() {
let mut x = 42;
let mut g = &mut x; // <---- lifecycle of g starts here
// |
println!("{}", x); //<--- Cannot borrow `x` when something can change `x`.
// | i.e., cannot borrow `x` when there is an
// | active mutable borrow of `x`.
// |
*g = *g + 1; // <---- lifecycle of g ends here.
// i.e. x's borrow ends here.
}
Mutable Variable to a Mutable Reference
The mutability of the reference itself (i.e., whether g can be reassigned) is separate from the mutability of the value it points to.
There are some scenarios where you might want a mutable variable holding a mutable reference, but they’re not common. One example might be if you wanted to reassign g to point to different mutable references:
let mut x = 5;
let mut y = 10;
let mut g = &mut x;
*g += 1;
g = &mut y; // Reassigning g to point to y
*g += 1;
But in most cases, you don’t need the extra mutability on the variable holding the reference. It’s generally cleaner and clearer to keep things as immutable as possible whenever you can.
Give It Time to Sink In
The borrow checker can be frustrating for newcomers. Sometimes requires rethinking algorithms or data structures to satisfy the borrow checker.
As you work more with Rust, you’ll develop an intuition for what the borrow checker allows and disallows. It’s a powerful tool that, once understood, allows you to write safe and efficient code with confidence.
The key thing to remember is that Rust’s borrow checker ensures that:
- You don’t have multiple mutable references to the same data simultaneously.
- You don’t have a mutable reference and an immutable reference to the same data simultaneously.
- Last Use Principle: A borrow is considered to end after its last use in the control flow of the program.
- Compiler Analysis: The Rust compiler analyzes the code to determine where references are no longer used.
Thus, at any given point in the program’s execution, there can be only one mutable reference to a particular piece of data. This is indeed true not just within a function, but across the entire program.
This means, Rust’s borrow checker works based on the potential for use, not actual use. It doesn’t wait to see if you actually use the references.
Summarizing Ownership and Borrowing
Let’s summarize the key points about ownership and borrowing in Rust that we’ve covered so far:
- Every value in Rust has an owner (which is a variable).
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped.
- References allow you to refer to a value without taking ownership.
- In Rust, we say “reference to” rather than “pointer to”.
- The
&
symbol is used to create a reference. - At any given time, you can have either:
- One mutable reference (
&mut T
) - Any number of immutable references (
&T
) - But not both at the same time.
- One mutable reference (
- References must always be valid (no dangling references).
- You can borrow a variable mutably or immutably.
- Mutable borrows are exclusive; immutable borrows are not.
- A borrow’s lifetime ends at its last use, not necessarily at the end of its lexical scope. This is known as Non-Lexical Lifetimes (NLL).
- You can have multiple mutable borrows of different variables simultaneously.
- You can’t have multiple simultaneous mutable borrows of the same variable.
- Using a variable often creates an implicit borrow.
- You can use the original variable once all mutable borrows of it have ended.
- Rust’s borrow checker enforces these rules at compile-time.
- It analyzes the flow of borrows through the program.
- The borrow checker prevents data races and ensures memory safety.
- The borrow checker has become more flexible with Non-Lexical Lifetimes.
- It now considers the actual usage of borrows, not just their lexical scope.
These rules and concepts form the foundation of Rust’s memory safety guarantees, allowing for safe concurrent programming without a garbage collector.
Structs and Methods
Structs in Rust are similar to structs in C or classes in other languages. They allow you to create custom data types by grouping related data together. Methods are functions associated with a particular struct.
Let’s start with a basic example:
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
fn new(width: u32, height: u32) -> Rectangle {
Rectangle { width, height }
}
}
fn main() {
let rect = Rectangle::new(30, 50);
println!(
"The area of the rectangle is {} square pixels.",
rect.area()
);
}
- In the above code, we define a
Rectangle
structwith
width andheight
fields. Fields in a struct are private by default. - The
impl
block is where we define methods associated with the Rectangle struct. area
is an instance method. It takes&self
as its first parameter, which is a reference to the instance.new
is an associated function (similar to a static method in other languages). It doesn’t takeself
and is often used as a constructor.
Method Receivers
&self
for read-only access to the instance&mut self
for mutable accessself
for taking ownership (rare)
Associated Functions
- Functions in the
impl
block that don’t take self are called associated functions. - They are often used for constructors or utility functions related to the struct.
Multiple impl Blocks
You can have multiple impl blocks for a struct, which is useful for organizing code.
Taking Ownership of self
&mut self
is a mutable reference to the instance. The method can modify the instance, but doesn’t take ownership of it. This means, after the method call, the caller still owns the instance and can use it.
Whereas self
takes ownership of the instance. We say that “the method consumes the instance”, meaning the caller can no longer use it after the method call ends.
This is less common and is typically used when you want to transform the instance into something else or when you’re done with it and want to essentially clean it up and prevent further use.
Here is an example:
struct Counter {
count: u32,
}
impl Counter {
// Method with &mut self
fn increment(&mut self) {
self.count += 1;
}
// Method with self
fn reset(self) -> Counter {
Counter { count: 0 }
}
}
fn main() {
let mut counter = Counter { count: 5 };
counter.increment();
println!("Count: {}", counter.count); // This is fine
let new_counter = counter.reset();
// println!("Old count: {}", counter.count); // This would be an error
println!("New count: {}", new_counter.count); // This is fine.
}
The choice between &mut self and self depends on what you want to do with the instance:
- Use
&mut self
when you want to modify the instance but keep using it. - Use
self
when you’re transforming the instance into something else or when you’re done with it and want to ensure it’s not used again.
Enums and Pattern Matching
Enums (short for enumerations) allow you to define a type by enumerating its possible variants.
Rust enums are much more flexible than Go enums.
Here’s a basic example of an enum and how to use pattern matching with it:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn process_message(msg: Message) {
match msg {
Message::Quit => {
println!("Quitting");
}
Message::Move { x, y } => {
println!("Moving to ({}, {})", x, y);
}
Message::Write(text) => {
println!("Writing: {}", text);
}
Message::ChangeColor(r, g, b) => {
println!("Changing color to ({}, {}, {})", r, g, b);
}
}
}
fn main() {
let msg1 = Message::Move { x: 10, y: 20 };
let msg2 = Message::Write(String::from("Hello, Rust!"));
process_message(msg1);
process_message(msg2);
}
- Variants: Each enum can have multiple variants (
Quit
,Move
,Write
,ChangeColor
). - Data in variants: Variants can hold different types and amounts of data.
- No null: Enums are often used instead of null values in Rust.
Advanced Pattern Matching
Pattern matching with match
:
- Exhaustive: match must cover all possible variants of the enum.
- Binding: You can bind variables to the data inside enum variants.
- Multiple patterns: You can match multiple patterns with
|
. - Catch-all:
_
can be used as a catch-all pattern.
Here’s an example of more advanced pattern matching:
enum OptionalInt {
Value(i32),
None,
}
fn describe_optional_int(oi: OptionalInt) {
match oi {
OptionalInt::Value(42) => println!("The answer!"),
OptionalInt::Value(n) if n > 100 => println!("Big number: {}", n),
OptionalInt::Value(n) => println!("Number: {}", n),
OptionalInt::None => println!("No value"),
}
}
fn main() {
describe_optional_int(OptionalInt::Value(42));
describe_optional_int(OptionalInt::Value(200));
describe_optional_int(OptionalInt::Value(50));
describe_optional_int(OptionalInt::None);
}
This example demonstrates:
- Matching specific values (
42
) - Using guards (
if n > 100
) - Binding variables (
n
) - Catch-all patterns
Enums and pattern matching are often used together in Rust for:
- Representing and handling different states
- Error handling (with the
Result
enum) - Optional values (with the
Option
enum)
Pattern Matching Order
In a match
expression, patterns are evaluated from top to bottom, and only the first matching pattern is executed. Once a match is found, the corresponding code block is run, and the rest of the patterns are not evaluated.
This behavior allows you to put more specific patterns before more general ones.
For example:
match some_value {
0 => println!("Zero"),
n if n < 0 => println!("Negative"),
_ => println!("Positive"),
}
If some_value is 0, only the first arm will execute. If it’s negative, only the second arm will execute. The last arm only executes if the previous patterns don’t match.
Guards in Pattern Matching
Guards are additional conditions you can add to a match arm using the if
keyword. They allow you to express more complex conditions than pattern matching alone.
Here’s how guards work:
*The pattern is matched first.
- If the pattern matches, the guard condition is evaluated.
- If the guard condition is
true
, the arm is selected. - If the guard condition is
false
, matching continues with the next arm.
Let’s look at an example:
enum Temperature {
Celsius(f32),
Fahrenheit(f32),
}
fn describe_temperature(temp: Temperature) {
match temp {
Temperature::Celsius(c) if c > 30.0 => println!("Hot day! {}°C", c),
Temperature::Celsius(c) if c < 10.0 => println!("Cold day! {}°C", c),
Temperature::Celsius(c) => println!("Moderate temperature: {}°C", c),
Temperature::Fahrenheit(f) if f > 86.0 => println!("Hot day! {}°F", f),
Temperature::Fahrenheit(f) if f < 50.0 => println!("Cold day! {}°F", f),
Temperature::Fahrenheit(f) => println!("Moderate temperature: {}°F", f),
}
}
fn main() {
describe_temperature(Temperature::Celsius(35.0));
describe_temperature(Temperature::Fahrenheit(68.0));
}
Guards are powerful because they allow you to express conditions that can’t be represented by patterns alone, such as ranges or complex logical conditions.
Remember, the order matters here too. More specific guards should come before more general ones to ensure they have a chance to match.
This Looks A Lot Like Haskell
Here are some key points of similarity between Rust and Haskell in the context of pattern matching:
- Algebraic Data Types: Rust’s enums are similar to Haskell’s algebraic data types. Both allow you to define types with multiple variants that can hold different kinds of data.
- Pattern Matching: Both languages use pattern matching as a core feature for working with these types. The syntax and exhaustiveness checking are quite similar.
- Guards: As we just discussed, both Rust and Haskell allow the use of guards in pattern matching to add extra conditions.
- Expression-based: Both languages treat if statements, match expressions, and many other constructs as expressions that return values.
- Strong Static Typing: Both languages have strong, static type systems that catch many errors at compile time.
However, Rust differs from Haskell in several important ways:
- Mutability: Rust allows controlled mutability, whereas Haskell is purely functional with immutable data by default.
- Memory Management: Rust uses its ownership system for memory management, while Haskell uses garbage collection.
- Side Effects: Rust doesn’t isolate side effects in the type system the way Haskell does with its IO monad.
- Performance Focus: Rust is designed as a systems programming language with a focus on performance, while Haskell is more oriented towards high-level abstraction.
This design philosophy reveals itself in many aspects, not only pattern matching: Rust’s design philosophy has been to take powerful ideas from functional programming (like those found in Haskell) and apply them in a way that’s suitable for systems programming.
This results in a language that can feel familiar to functional programmers in many ways, while still providing the low-level control needed for systems programming.
Patterns and Traits
We will cover traits later in this article. But, still, it’s a good time to compare patterns and traits and see when to use each.
Pattern matching and traits in Rust serve different purposes, although they can sometimes be used to solve similar problems.
Use pattern matching when:
- You want to destructure complex data types
- You need to handle multiple cases based on the structure or value of data
- You are working with enums and want to handle different variants
- You want to extract values from Option or Result types
For example:
- Matching on enum variants
- Destructuring tuples or structs
- Implementing control flow based on data structure
Use traits when:
- You want to define shared behavior across different types
- You need to implement polymorphism
- You are designing generic code that works with multiple types
- You want to extend the functionality of existing types without modifying their source code
For example:
- Defining common methods for different structs
- Implementing interfaces for various types
- Creating generic functions that work with any type implementing a specific trait
The following example illustrates the difference:
// Pattern matching example
enum Shape {
Circle(f64),
Rectangle(f64, f64),
}
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
}
}
// Trait example
trait Area {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Area for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
In this example, pattern matching is used to handle different variants of the Shape
enum, while traits are used to define a common Area
behavior for different struct types.
To summarize:
- Use pattern matching for control flow and data extraction based on the structure or value of data.
- Use traits for defining shared behavior across different types and creating generic, polymorphic code.
Collections in Rust
Let’s focus on three of the most commonly used collections:
Vec<T>
(Vector)HashMap<K, V>
(Hash Map)HashSet<T>
(Hash Set)
Vector
fn main() {
// Creating a vector
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
v.push(3);
// Another way to create a vector
let v2 = vec![1, 2, 3];
// Accessing elements
let third: &i32 = &v[2];
println!("The third element is {}", third);
// Safe access with get()
match v.get(2) {
Some(x) => println!("The third element is {}", x),
None => println!("There is no third element."),
}
// Iterating over values
for i in &v {
println!("{}", i);
}
// Mutating while iterating
for i in &mut v {
*i += 50;
}
}
- Vector provides a growable array type
- Elements are stored contiguously in memory
- Vector can add or remove elements from the end efficiently
Hash Map
use std::collections::HashMap;
fn main() {
// Creating a HashMap
let mut scores = HashMap::new();
// Inserting values
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
// Accessing values
let team_name = String::from("Blue");
let score = scores.get(&team_name);
// Updating a value
scores.entry(String::from("Yellow")).or_insert(50);
// Iterating over key/value pairs
for (key, value) in &scores {
println!("{}: {}", key, value);
}
}
- A Hash Map stores key-value pairs
- Allows you to look up data by using a key
- Uses hashing function to determine how to store the data
Hash Set
use std::collections::HashSet;
fn main() {
// Creating a HashSet
let mut fruits = HashSet::new();
// Inserting values
fruits.insert("Apple");
fruits.insert("Banana");
fruits.insert("Cherry");
// Checking for a value
println!("Contains Apple? {}", fruits.contains("Apple"));
// Removing a value
fruits.remove("Banana");
// Iterating over values
for fruit in &fruits {
println!("{}", fruit);
}
// Set operations
let mut set1 = HashSet::new();
set1.insert(1);
set1.insert(2);
let mut set2 = HashSet::new();
set2.insert(2);
set2.insert(3);
// Union
let union: HashSet<_> = set1.union(&set2).cloned().collect();
println!("Union: {:?}", union);
}
- A Hash Set stores unique values of type T
- Useful for eliminating duplicates
- Allows for efficient membership testing
Collections and Ownership
Rust collections interact with Rust’s ownership system:
- When you put a value into a collection, the collection becomes the owner of that value. That means the collection is responsible for cleaning up the value when it’s dropped. Also, no one else can use the value while it’s in the collection.
- When the collection is dropped, all of its contents are also dropped.
The use
Keyword
The use
keyword in Rust is indeed similar to import
in Go. Here are the key things to know about use
in Rust:
- Purpose: It brings items (like types, functions, or modules) into scope, allowing you to use them without fully qualified paths.
- Syntax: The basic syntax is use
path::to::item
; - Multiple items: You can bring multiple items from the same path using curly braces:
use std::collections::{HashMap, HashSet};
- Renaming: You can rename items as you import them using the
as
keyword:use std::collections::HashMap as Map;
- Nested paths: Rust allows nesting paths to avoid repetition:
use std::{collections::HashMap, io::Read}
; - Glob imports: You can bring all public items from a module into scope using
*
:use std::collections::*;
(this is generally discouraged except in specific cases like tests) - Re-exporting: You can combine
use
withpub
to re-export items:pub use self::some_module::SomeType
; - Prelude: Some common items are automatically imported via the standard prelude, so you don’t need to explicitly use them.
Generics
Generics allow you to write code that works with multiple types. Generics in Rust are similar to generics in languages like Java or C#, or templates in C++.
Rust’s generics are zero-cost abstractions, meaning they don’t add runtime overhead.
Let’s define a few generics:
// A generic struct
struct Pair<T> {
first: T,
second: T,
}
// A generic enum
enum Option<T> {
Some(T),
None,
}
// A generic function
fn print_pair<T: std::fmt::Display>(pair: &Pair<T>) {
println!("({}, {})", pair.first, pair.second);
}
// Generic implementation
impl<T> Pair<T> {
fn new(first: T, second: T) -> Pair<T> {
Pair { first, second }
}
}
fn main() {
// Using the generic struct with different types
let integer_pair = Pair::new(5, 10);
let float_pair = Pair::new(1.0, 2.0);
let string_pair = Pair::new(String::from("hello"),
String::from("world"));
print_pair(&integer_pair);
print_pair(&float_pair);
print_pair(&string_pair);
}
Here are some important points about generics:
Type Parameters
: The<T>
inPair<T>
is a type parameter. You can use any valid identifier, but single uppercase letters are conventional.- Multiple Type Parameters: You can have multiple type parameters, e.g.,
struct KeyValue<K, V> { key: K, value: V }
. - Trait Bounds: You can specify that a type parameter must implement certain traits, as in
T: std::fmt::Display
in theprint_pair
function. - Generic Functions: Functions can also be generic over types they accept or return.
- Generic Implementations: You can implement methods for generic types, as shown with the
new
method forPair<T>
. - Monomorphization: Rust generates specialized code for each concrete type used with a generic type or function, ensuring no runtime cost for generics.
Generics are widely used in Rust’s standard library, including in the collections we just discussed (Vec<T>
, HashMap<K, V>
, etc.).
Copy and Non-Copy Types
Let think about the following example:
fn main() {
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
v.push(3);
let v2 = vec![1, 2, 3];
// This borrows an element of the vector.
let third = &v[2];
// This copies an element of the vector
// and it is valid because i32 is a Copy type:
//
// let third = v[2];
println!("The third element is '{}'.", third)
}
Instead of let third = &v[2];
, if we change it to let third = v[2];
, (i.e., if we copy the value instead of borrowing it), the code would still compile.
Here are a few things that would happen in that case:
- If you change
let third = &v[2];
tolet third = v[2];
, the code would still compile and run correctly. - Instead of borrowing the value, you’d be copying it.
The key difference is in how Rust treats the types involved:
i32
(32-bit integer) is a “Copy” type in Rust. This means it’s small and stored entirely on the stack, so Rust will copy its value rather than move it.
For “Copy” types, there’s no significant performance difference between borrowing and copying for small, simple types like integers. But still you’d want to borrow for several reasons:
- Consistency: Borrowing is a common pattern in Rust, especially when working with collections.
- Flexibility: If you later change the vector to contain a non-Copy type (like
String
), the borrowing approach would still work without modification. - Preventing Accidental Mutations: If
v
were mutable, and you wanted to ensure third always reflected the current state of the vector, a reference would be necessary.
Here’s an example to illustrate the difference with a non-Copy type:
fn main() {
let mut v: Vec<String> = Vec::new();
v.push(String::from("hello"));
v.push(String::from("world"));
// This borrows - it's always valid
let third_ref = &v[1];
println!("Borrowed: {}", third_ref);
// This moves the String out of the vector!
// let third_owned = v[1]; // This would cause an error
// println!("Owned: {}", third_owned);
// Instead, we could clone if we really need ownership
let third_cloned = v[1].clone();
println!("Cloned: {}", third_cloned);
}
In this case:
- Borrowing with
&v[1]
is efficient and doesn’t change the vector. - Trying to move with
v[1]
would actually be an error, as it would leave a “hole” in the vector. - If you need ownership, you’d typically clone the value explicitly.
The decision to borrow by default is part of Rust’s design philosophy, encouraging developers to think about ownership and minimize unnecessary copying or moving of data.
Syntactical Similarities Between Rust and Go
If you squint hard enough, Rust reads just like Go.
Rust and Go share some syntactical similarities, but they differ significantly in their underlying philosophies and features, particularly when it comes to memory management and type systems.
Let’s explore this a bit:
Similarities Between Rust and Go
- Syntax for basic constructs:
- Curly braces for blocks
- Similar function declaration syntax
- Use of
let
(Rust) and:=
(Go) for variable declaration
- Focus on systems programming and performance
- Built-in concurrency support (though implemented differently)
- Strong static typing
- Emphasis on simplicity and readability
Key Differences Between Rust and Go
- Memory Management
- Rust: Ownership system, borrow checker, no garbage collection
- Go: Garbage collection
- Generics
- Rust: Robust generics system
- Go: Only recently added generics (Go 1.18+), more limited
- Null Safety
- Rust: No null, uses
Option<T>
- Go: Has nil, no built-in null safety
- Rust: No null, uses
- Error Handling
- Rust:
Result<T, E>
type, no exceptions - Go: Multiple return values, error as second return
- Rust:
- Inheritance
- Rust: No inheritance, uses traits for shared behavior
- Go: No inheritance, uses interfaces
- Lifetimes
- Rust: Explicit lifetime annotations
- Go: No concept of lifetimes
- Compile-time Guarantees
- Rust: Prevents data races, null pointer dereferences at compile time
- Go: Relies more on runtime checks
Let’s compare two similar code snippets in Rust and Go:
Rust:
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let sum: i32 = numbers.iter().sum();
println!("Sum: {}", sum);
}
Go:
package main
import "fmt"
func main() {
numbers := []int{1, 2, 3, 4, 5}
sum := 0
for _, num := range numbers {
sum += num
}
fmt.Println("Sum:", sum)
}
While the basic structure looks similar, Rust’s version showcases its powerful iterator system and functional programming features.
Rust’s design choices, particularly around ownership and borrowing, aim to provide memory safety without garbage collection, which is a key distinguishing feature from Go.
About <_>
(Type Inference)
Let’s examine the following code snippet:
fn main() {
let set1: HashSet<i32> = vec![1, 2, 3].into_iter().collect();
let set2: HashSet<i32> = vec![3, 4, 5].into_iter().collect();
// Using type inference with <_>
let union: HashSet<_> = set1.union(&set2).cloned().collect();
println!("Union with <_>: {:?}", union);
// Explicitly specifying the type
let union_explicit: HashSet<i32> = set1.union(&set2).cloned().collect();
println!("Union with explicit type: {:?}", union_explicit);
// Without cloned(), we'd need to collect references
let union_refs: HashSet<&i32> = set1.union(&set2).collect();
println!("Union of references: {:?}", union_refs);
}
<_>
The underscore _
in <_>
here is a placeholder that tells the Rust compiler to infer the type. In this case, it’s inferring the type of elements in the HashSet
based on the input.
If set1
and set2
contain integers, Rust will infer HashSet<i32>
(or whatever the specific integer type is).
So, <_>
is a way to say “figure out the type for me” rather than explicitly specifying it.
.cloned()
union()
returns an iterator of references to the elements..cloned()
creates an iterator that clones each element.
This is necessary because union()
returns references, but we want to own the values in our new HashSet
.
Without .cloned()
, you might think that we’d have an iterator of references, which can’t be collected into a new owned HashSet
. But, again, Rust is smart enough to handle this for us:
// The elements are automatically cloned when collected into the HashSet.
let union: HashSet<i32> = set1.union(&set2).collect();
Compare this with the next code:
// .cloned() creates an iterator of references to cloned values.
let union: HashSet<&i32> = set1.union(&set2).cloned().collect();
.collect()
This method transforms an iterator into a collection. It’s very flexible and can create different types of collections depending on the context. Here, because we specified HashSet<_>
, collect()
knows to create a HashSet
.
collect()
is part of Rust’s powerful iterator system, allowing for efficient, expressive data processing.
Key points:
set1.union(&set2)
returns an iterator over&i32
.- When we
collect()
withoutcloned()
, Rust automatically dereferences and clones the values to create aHashSet<i32>
. - When we use cloned(), we’re creating an iterator of
&i32
, which then collects into aHashSet<&i32>
. cloned()
works with any type that implementsClone
, not just with types that have specialFromIterator
implementations.
That being said, using cloned()
makes it clear in the code that you’re intentionally creating clones. It can make the code more self-documenting.
Here is another variation of this theme:
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// Without cloned(), we work with &i32 references.
let doubled: Vec<i32> = numbers.iter().map(
|&x| x * 2).collect();
// With cloned(), we can work directly with i32 values
let doubled_explicit: Vec<i32> = numbers.iter()
.cloned().map(|x| x * 2).collect();
println!("Doubled: {:?}", doubled);
println!("Doubled explicit: {:?}", doubled_explicit);
// Here's an example where cloned() is necessary:
let strings = vec![String::from("hello"), String::from("world")];
let first_chars: Vec<char> = strings.iter().map(
|s| s.chars().next().unwrap()
).collect();
// If we wanted to modify these strings, we'd need cloned().
// Remember that iter() returns references to String (`&String`);
// you cannot map over `&String` and modify it.
//
// Why? Because the trait `FromIterator<&String>` is not implemented for
// `Vec<String>`. So, you will need `Strings`s as elements to `collect()`
// them (instead of `&String`s).
//
let modified: Vec<String> = strings.iter().cloned().map(|mut s| {
s.push('!');
s
}).collect();
println!("First chars: {:?}", first_chars);
println!("Modified: {:?}", modified);
println!("Original strings: {:?}", strings); // Unchanged
}
In summary, while cloned()
might seem redundant in some cases like the HashSet
example above, it becomes crucial when:
- You need to perform operations that require owned values.
- You’re working with types that don’t have specialized
FromIterator
implementations. - You want to make cloning explicit in your code for clarity.
- You’re doing complex iterator chains where owning the values is necessary at some intermediate step.
Purpose of collect()
collect()
consumes an iterator and creates a collection from its elements. It’s part of Rust’s “zero-cost abstractions” philosophy, often being as efficient as manual iteration.
collect()
can create many different collection types, not just Vec
or HashSet
. The type it creates is often inferred from context, but can be explicitly specified.
For many common cases, collect()
is optimized to allocate the correct amount of memory upfront.
In addition collect()
can also be used to accumulate Result
s, turning an iterator of Result
s into a Result
of a collection.
Let’s compare this with an imperative approach:
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// Using collect()
let doubled: Vec<i32> = numbers.iter().map(|&x| x * 2).collect();
// Imperative approach
let mut doubled_imperative = Vec::new();
for &num in &numbers {
doubled_imperative.push(num * 2);
}
println!("Doubled (collect): {:?}", doubled);
println!("Doubled (imperative): {:?}", doubled_imperative);
// collect() with type inference
let set: HashSet<_> = numbers.iter().cloned().collect();
// collect() for error handling
let results: Vec<Result<i32, &str>> =
vec![Ok(1), Err("oops"), Ok(3)];
let collected_results:
Result<Vec<i32>, &str> = results.into_iter().collect();
println!("Set: {:?}", set);
println!("Collected results: {:?}", collected_results);
}
Here are some highlights:
- The
collect()
version is more concise and focuses on the transformation (map), not the mechanics of building the collection. collect()
can infer the type of collection to create, making it very flexible.- It can be used in more complex scenarios, like accumulating Result types.
Yes, you can always write an imperative loop to achieve the same result, however, collect()
offers several advantages:
- It’s more declarative, focusing on what you want, not how to do it.
- It’s often more concise and can be chained with other iterator methods.
- It can be more efficient in some cases, as the compiler can optimize it better.
- It’s more flexible, easily allowing you to collect into different types of collections.
In essence, collect() is a bridge between Rust’s iterator system and its collections, embodying Rust’s approach to combining high-level abstractions with low-level performance.
Result
Type in Rust
Result
is a crucial type in Rust’s error handling system. It is an enum
used for returning and propagating errors. It’s defined as follows:
enum Result<T, E> {
Ok(T),
Err(E),
}
Let’s break it down:
Ok(T)
: Represents success and contains a value of typeT
Err(E)
: Represents an error and contains an error value of typeE
Result
is used for operations that can fail. It allows you to handle errors explicitly without exceptions.
Result
is often used as the return type for functions that might fail. It forces error handling, improving reliability
Here’s a simple example:
use std::fs::File;
use std::io::Read;
fn read_file_contents(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_file_contents("example.txt") {
Ok(contents) => println!("File contents: {}", contents),
Err(error) => println!("Error reading file: {}", error),
}
}
- The function returns
Result<String, std::io::Error>
- On success, it returns
Ok(contents)
- On failure, it returns
Err(error)
In File::open(path)?;
, the ?
operator is used for easy error propagation. It’s shorthand for a match expression that returns early on error.
Here is a more explicit version:
let mut file = match File::open(path) {
Ok(file) => file,
Err(e) => return Err(e.into()),
};
In main()
, we use pattern matching to handle both success and failure cases.
Here are other some common ways to work with Result
:
// Don't care about error details.
if let Ok(contents) = read_file_contents("example.txt") {
println!("File contents: {}", contents);
}
// Expect panics with a custom message.
let contents = read_file_contents("example.txt")
.expect("Failed to read file");
// A combination
let line_count = read_file_contents("example.txt")
.map(|contents| contents.lines().count())
// .unwrap() panics, unwrap_or() provides a default value.
.unwrap_or(0);
Result
is a key part of Rust’s approach to creating reliable, error-resistant code. It encourages explicit error handling and helps prevent issues like unchecked null values or unhandled exceptions found in some other languages.
Result
versus Promises
The Result
type in Rust does share some conceptual similarities with Deferreds, Promises, and Monads. Let’s explore these connections:
Similarity to Promises/Deferreds
Like Promises, Result
represents the outcome of an operation that may succeed or fail. Both provide a structured way to handle success and failure cases. However, Result
is synchronous, while Promises typically deal with asynchronous operations.
Monadic Nature
Result
is indeed very similar to a Monad
, particularly the Either
monad in functional programming languages. It provides a way to chain operations that might fail, similar to how Monads allow sequencing of computations.
Here’s how Result exhibits monad-like behavior:
fn divide(x: i32, y: i32) -> Result<i32, String> {
if y == 0 {
Err("Division by zero".to_string())
} else {
Ok(x / y)
}
}
fn main() {
let result = divide(10, 2)
.and_then(|x| divide(x, 2))
.map(|x| x * 2);
match result {
Ok(value) => println!("Result: {}", value),
Err(e) => println!("Error: {}", e),
}
}
In this example:
and_then
is similar toflatMap
orbind
in other monadic systems.map
allows transforming the success value, similar to functor operations.
Result
is monadic because it allows:
- Encapsulation of computation,
- Sequencing of operations,
- And error short-circuiting.
However, there are differences, too:
Result
is synchronous and immediately available.- It’s used for error handling rather than managing asynchronous operations.
Rust’s approach with Result:
- Enforces explicit error handling at compile-time.
- Provides a type-safe way to handle errors without exceptions.
- Allows for expressive, functional-style composition of fallible operations.
The similarity to monads and functional programming concepts is not accidental. Rust incorporates many ideas from functional programming, including this approach to error handling, while still maintaining its systems programming focus.
std::fmt::Display
In the above code, std::fmt::Display
is a trait that allows for text-based display of a type. It’s similar to Stringer
in Go or toString()
in other languages.
std
is the standard library.fmt
is a module within the standard library for formatting and display.- The
::
is used to access items within modules.
This hierarchical structure helps organize code and prevent naming conflicts.
`Display as a Trait
Display is a trait.
Traits in Rust are similar to interfaces in other languages, but more powerful. They define a set of methods that a type must implement. You can think of them as defining an “ability” or “behavior” that a type can have.
In the context of the above example code, T: std::fmt::Display
is a trait bound. It means, “T can be any type that implements the Display
trait”.
The Display
trait defines how a type should be formatted for user-facing output.
Here’s a brief example of how a type implements Display
:
use std::fmt;
struct Point {
x: i32,
y: i32,
}
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p = Point { x: 1, y: 2 };
println!("{}", p); // Prints: (1, 2)
}
- Traits (like
Display
) define a contract that types can implement. - They are used extensively in Rust for polymorphism and to define shared behavior.
- In generic functions, trait bounds (like
T: std::fmt::Display
) specify what capabilities the generic types must have.
So, in our print_pair
function, it can work with any Pair<T>
where T
is any type that knows how to display itself (i.e., implements Display
).
This is how Rust achieves polymorphism and code reuse without traditional inheritance.
You can also bind multiple traits to a type.
You can use the +
syntax:
fn print_info<T: std::fmt::Display + std::fmt::Debug>(value: T) {
println!("Display: {}", value);
println!("Debug: {:?}", value);
}
Or, you can use where
clauses for more complex bounds:
fn complex_function<T, U>(t: T, u: U) -> i32
where
T: std::fmt::Display + Clone,
U: std::fmt::Debug + PartialEq,
{
// Function body
0
}
You can use traits in trait definitions too: In trait definitions:
trait MyTrait: std::fmt::Display + std::fmt::Debug {
// Trait methods
}
Also, traits can be helpful in struct or enum definitions with trait bounds:
struct Wrapper<T: std::fmt::Display + std::fmt::Debug>(T);
Here’s a more complete example:
use std::fmt;
// A trait for things that can be doubled
trait Doubler {
fn double(&self) -> Self;
}
// Implement Doubler for i32
impl Doubler for i32 {
fn double(&self) -> Self {
self * 2
}
}
// A function that uses multiple trait bounds
fn double_and_print<T: Doubler + fmt::Display>(value: T) {
let doubled = value.double();
println!("Original: {}, Doubled: {}", value, doubled);
}
fn main() {
double_and_print(5);
}
About Self
Self
is a special keyword in Rust that refers to the type that’s implementing a trait or method. It’s a way to refer to the current type without naming it explicitly.
- In trait definitions:
Self
represents the type that will implement the trait. - In method implementations:
Self
refers to the type theimpl
block is for. Self
is always capitalized when used as a type.
Here are some examples:
In a trait definition:
trait Cloneable {
fn clone(&self) -> Self;
}
Here, Self
will be whatever type implements this trait.
In an impl block:
struct Point {
x: i32,
y: i32,
}
impl Point {
fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
In this case, Self
is equivalent to Point
.
In trait implementations:
impl Cloneable for Point {
fn clone(&self) -> Self {
Self {
x: self.x,
y: self.y,
}
}
}
As a return type:
trait Builder {
fn reset(&mut self) -> &mut Self;
}
impl Builder for Point {
fn reset(&mut self) -> &mut Self {
self.x = 0;
self.y = 0;
self
}
}
In associated types:
trait Container {
type Item;
fn contains(&self, item: &Self::Item) -> bool;
}
Here are some important notes about Self
:
- It allows for more generic and reusable code.
- It is particularly useful in traits where you don’t know the concrete type implementing the trait.
- It can be used as a return type, parameter type, or in type annotations within methods or associated functions.
- It helps in writing self-referential structs or in implementing the builder pattern.
One thing to note is Self
refers the type itself, not an instance of the type. This is different from self
(lowercase), which refers to the current instance.
With Borrowing, Do We “Ever” Need Pointers?
With all this reference and borrowing semantics, you will seldom need to use pointers in Rust. Instead of raw pointers, references are much more frequently used and are safer due to the borrow checker.
- References (
&
and&mut
) are the primary way to share access to data without transferring ownership. - References are always valid (non-null) and checked by the borrow checker.
Rust’s reference system provides memory safety guarantees at compile time. This prevents common issues like null pointer dereferencing, dangling pointers, and data races.
Raw pointers (*const T
and *mut T
) do exist in Rust, but they’re used much less frequently. They’re primarily used in unsafe
code blocks for low-level operations or interfacing with C libraries.
Here’s a comparison:
fn main() {
let x = 5;
// Reference (safe, common)
let ref_x = &x;
println!("Reference value: {}", *ref_x);
// Raw pointer (unsafe, uncommon)
let ptr_x = &x as *const i32;
unsafe {
println!("Pointer value: {}", *ptr_x);
}
}
Pointers can (as a very very VERY last resort) be used to work around the limitations of the borrow checker.
Rust’s approach offers several advantages:
- Memory safety without garbage collection
- Elimination of entire classes of bugs at compile time
- Clear ownership semantics
- Performance comparable to manual memory management
This design allows Rust to achieve its goal of being a systems programming language that is safe, concurrent, and practical.
Lifetimes
Lifetimes are a crucial and unique feature of Rust that deserve their own discussion.
Lifetimes are Rust’s way of ensuring that references are valid for the duration they are used. They are part of Rust’s borrow checker, preventing use-after-free and dangling pointer errors.
Why Do We Need Lifetimes?
- Lifetimes help the compiler ensure that references don’t outlive the data they refer to.
- They allow for more complex borrowing patterns while maintaining memory safety.
Implicit Lifetime Elision
In many cases, Rust can infer lifetimes without explicit annotation.
Explicit Lifetime Annotation
You can use the syntax 'a
, 'b
, etc. (the names are arbitrary but 'a
is conventional for a single lifetime) to annotate lifetimes explicitly.
Let’s see some examples:
// Implicit lifetime
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
// Explicit lifetime
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = String::from("short");
let string2 = String::from("longer");
let result = longest(&string1, &string2);
println!("Longest string is {}", result);
}
In the explicit version:
'a
is a lifetime parameter.- It tells the compiler that all the references in the arguments and return value must have the same lifetime.
Rust has rules to infer lifetimes in common patterns, reducing the need for explicit annotations. That’s called lifetime elision.
Let’s see another example:
struct ImportantExcerpt<'a> {
part: &'a str,
}
This struct can’t outlive the reference it holds.
Static Lifetime
'static
is a special lifetime that lasts for the entire program execution.
Multiple Lifetimes
You can specify different lifetimes for different references:
fn complex<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
x
}
Lifetime Bounds
You can specify that a type must live for a certain lifetime:
fn print_type<T: Display + 'static>(t: &T) {
println!("{}", t);
}
Lifetimes are one of the more challenging concepts in Rust, but they’re crucial for the language’s memory safety guarantees without garbage collection.
Let’s see some more complex scenarios involving lifetimes.
Lifetimes with Structs and Implementation Blocks
In Rust, when you use references in struct definitions, you need to specify lifetime parameters to help the compiler ensure that the references remain valid for as long as the struct instance exists.
For example, the following code will raise an error:
struct Excerpt {
text: &str,
// ^ expected named lifetime parameter!
}
Let’s fix that below:
struct Excerpt<'a> {
text: &'a str,
}
impl<'a> Excerpt<'a> {
fn level(&self) -> i32 {
3
}
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.text
}
}
fn main() {
let novel = String::from("Call me Volkan. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = Excerpt {
text: first_sentence,
};
println!("Excerpt level: {}", i.level());
println!("Excerpt: {}", i.announce_and_return_part("Here's an excerpt"));
}
In the code above, <'a>
declares a lifetime parameter that the struct uses, and &'a str
specifies that the text field must live for at least as long as this lifetime 'a'
. Any instance of Excerpt
is then constrained to live no longer than the concrete lifetime that 'a'
represents when the struct is instantiated.
For example:
fn main() {
let long_lived_string = String::from("I live long");
let excerpt;
{
let short_lived_string = String::from("I'm short-lived");
// We borrow from short_lived_string
excerpt = Excerpt { text: &short_lived_string };
} // short_lived_string goes out of scope here
// We cannot used excerpt here because short_lived_string
// is no longer valid.
} // long_lived_string and excerpt go out of scope here
Let’s compare it with this one:
fn main() {
let long_lived_string = String::from("I live long");
let excerpt;
{
let short_lived_string = String::from("I'm short-lived");
// We borrow from long_lived_string
excerpt = Excerpt { text: &long_lived_string };
} // short_lived_string goes out of scope here
// We can use excerpt here because `'a` refers to the lifetime of
// long_lived_string which is still valid.
println!("value: {}", excerpt.text)
} // long_lived_string and excerpt go out of scope here
Lifetimes With Trait Bounds
The next example demonstrates how lifetimes can be used with traits and trait implementations:
trait Summarizable<'a> {
fn summary(&self) -> &'a str;
}
struct Article<'a> {
title: &'a str,
content: &'a str,
}
impl<'a> Summarizable<'a> for Article<'a> {
fn summary(&self) -> &'a str {
self.title
}
}
fn main() {
let article = Article {
title: "Rust Lifetimes",
content: "Lifetimes are a complex but powerful feature...",
};
println!("Article summary: {}", article.summary());
}
Higher-Rank Trait Bounds (HRTB)
In type theory, “higher-rank” refers to types that involve quantifiers (like “for all” or “exists”) in positions other than just the outermost level. In Rust, this translates to lifetimes or types that are quantified in nested positions.
The following example introduces Higher-Rank Trait Bounds, allowing for more flexible lifetime relationships.
trait Matcher<T> {
fn match_with(&self, item: T) -> bool;
}
// A function that works with any reference lifetime
fn match_any<T, M>(matcher: &M, items: &[T]) -> bool
where
M: for<'a> Matcher<&'a T>,
{
items.iter().any(|item| matcher.match_with(item))
}
struct StartsWith(char);
impl<'a> Matcher<&'a &str> for StartsWith {
fn match_with(&self, item: &'a &str) -> bool {
item.starts_with(self.0)
}
}
fn main() {
let items = vec!["apple", "banana", "cherry"];
let matcher = StartsWith('b');
println!("Matched: {}", match_any(&matcher, &items));
}
Why is the above trait bound considered higher-rank?
- It’s saying that
M
must implementMatcher<&'a T>
for all possible lifetimes'a
. - This is different from a simple generic lifetime because it’s quantifying the lifetime at the trait bound level, not at the function level.
- It’s essentially a “forall” quantifier nested inside the type signature, hence “higher-rank”.
Lifetime Subtyping
Lifetime subtyping is an important concept in Rust’s borrowing and lifetime system. It allows for more flexible and expressive code when dealing with references that have different lifetimes.
Lifetime subtyping is based on the idea that one lifetime can be a “subtype” of another, meaning it lives at least as long as the other.
We express this relationship as 'a: 'b
, which is read as “'a
outlives 'b
” or “'a
is a subtype of 'b
”.
If 'a: 'b
, then any reference with lifetime 'a
can be used where a reference with lifetime 'b
is expected. This is because 'a
is guaranteed to live at least as long as 'b
, so it’s safe to use in place of 'b
.
Here is an example:
struct Context<'s>(&'s str);
struct Parser<'c, 's: 'c> {
context: &'c Context<'s>,
}
impl<'c, 's> Parser<'c, 's> {
fn parse(&self) -> Result<(), &'s str> {
Err(&self.context.0[1..])
}
}
fn parse_context(context: Context) -> Result<(), &str> {
Parser { context: &context }.parse()
}
fn main() {
let context = Context("Hello, world!");
let result = parse_context(context);
println!("Result: {:?}", result);
}
This showcases lifetime subtyping, where one lifetime must outlive another.
Note that the last few examples are more advanced use cases of lifetimes that you might not encounter frequently in everyday Rust programming. Regardless, they demonstrate the flexibility and power of Rust’s lifetime system.
Let’s use a different example to illustrate lifetime without any annotation to see how Rust infers lifetimes, and how you can intuitively reason about them:
fn main() {
let title: &str;
let content: &str;
let article: Article;
{
let t = String::from("Rust Lifetimes");
let c = String::from("Lifetimes are a complex but powerful...");
title = &t;
content = &c;
article = Article { title, content };
// Article can be used here
println!("Title: {}", article.title);
} // t and c go out of scope here
// This would cause a compile error:
// println!("Title: {}", article.title);
}
It’s good that Rust infers most of the lifetimes for us. Otherwise, the above code would have looked something like this:
struct Article<'a, 'b> {
title: &'a str,
content: &'b str,
}
fn main<'main>() {
let title: &'main str;
let content: &'main str;
let article: Article<'main, 'main>;
{
let t: String = String::from("Rust Lifetimes");
let c: String = String::from("Lifetimes are a complex but powerful...");
title = &'main t;
content = &'main c;
article = Article { title, content };
// Article can be used here
println!("Title: {}", article.title);
} // t and c go out of scope here
// This would cause a compile error:
// println!("Title: {}", article.title);
}
In this example, Article
can’t be used after t
and c
go out of scope because its lifetime is constrained by the lifetimes of the references it holds.
The Rust compiler ensures that article is not used beyond the scope where both title and content are valid.
To be clear, lifetimes express constraints, not durations.
- The
'a
inArticle<'a>
means “this struct is parameterized by some lifetime'a
”. It does NOT mean, “this struct’s life is bound by'a
” - The
'a
on the fields means “these references must be valid for at least the lifetime'a
”. - The struct itself is bound by these constraints–it cannot be used in a context where these constraints aren’t met.
So, more precisely:
- The
<'a>
doesn’t impose a constraint; it declares a lifetime parameter. - The uses of
'a
in the field types create the actual constraints, tying the struct’s usable lifetime to the lifetimes of its references.
Here’s another way to think about it:
struct Article<'a> {
title: &'a str,
content: &'a str,
}
The above is conceptually similar to how we use generic types:
struct Pair<T> {
first: T,
second: T,
}
In both cases, the angle brackets introduce a parameter (lifetime or type) that’s then used within the struct definition.
The key point is that Article<'a>
is defining a family of types, one for each possible concrete lifetime. The compiler then ensures that whenever an Article
is instantiated, all the lifetimes line up correctly.
Closures
Let’s shift our focus to closures in Rust. Closures in Rust have some similarities to those in Go, but there are also significant differences due to Rust’s unique features like ownership and borrowing.
Let’s start with a basic overview and then compare with Go:
In Rust, a closure looks like this:
let add_one = |x| x + 1;
Rust can often infer the types, but you can also be explicit:
let add_one: fn(i32) -> i32 = |x: i32| x + 1;
Rust closures can capture variables from their environment:
let y = 5;
let add_y = |x| x + y;
Now, let’s compare with Go:
- Similarities
- Both Rust and Go support closures (anonymous functions that can capture their environment).
- Both allow closures to be passed as arguments and returned from functions.
- Key Differences
- Syntax
- Rust uses
|params| body
syntax. - Go uses
func(params) { body }
syntax.
- Rust uses
- Capture Mechanism
- Rust has more complex capture rules due to its ownership system.
- Go simply captures by reference.
- Syntax
Mutability
Rust requires explicit mutability:
let mut x = 5;
let mut add_to_x = |y| {
x += y;
x
};
Ownership and Borrowing
Rust closures interact with the borrow checker:
let v = vec![1, 2, 3];
let print_vec = || println!("{:?}", v); // Borrows v
print_vec();
// v can be used here because the borrow has ended
Move Semantics
Rust allows forcing ownership transfer into the closure:
let v = vec![1, 2, 3];
let print_vec = move || println!("{:?}", v);
// v is moved into the closure and can't be used here
Closure Traits:
Rust closures implement traits like Fn
, FnMut
, or FnOnce
, which determines how they can be called and what they can do with captured variables.
Here’s a more complex example in Rust:
fn create_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
fn main() {
let add_5 = create_adder(5);
println!("5 + 10 = {}", add_5(10)); // Outputs: 5 + 10 = 15
}
This demonstrates returning a closure and using the move keyword to transfer ownership of captured variables.
So, while Rust and Go both support closures, Rust’s implementation is more complex due to its ownership system. This complexity brings additional safety guarantees and performance optimizations that aren’t present in Go’s simpler model.
Concurrency
Rust provides OS-level threads through the standard library using threads.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Hello from a thread!");
});
handle.join().unwrap();
}
Unlike Go’s lightweight goroutines, these are OS threads.
Rust uses channels for communication between threads, similar to Go:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send("Hello from a thread!").unwrap();
});
println!("Received: {}", rx.recv().unwrap());
}
Rust allows shared state concurrency, but with strict rules to prevent data races:
use std::sync::{Arc, Mutex};
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());
}
It’s also worth noting that Rust uses OS-level threads, which are heavier than Go’s goroutines. In addition, Rust doesn’t have a built-in scheduler like Go’s runtime.
One more nuance is Rust’s ownership rules apply to concurrent code, ensuring thread safety. In Go, you need to be careful about sharing mutable state between goroutines.
Async/Await
Rust also has async/await syntax for asynchronous programming:
use async_std::task;
async fn say_hello() {
println!("Hello, async world!");
}
fn main() {
task::block_on(async {
say_hello().await;
});
}
This is more similar to Go’s goroutines in terms of lightweight concurrency.
No Built-in Select
Rust doesn’t have a built-in select statement like Go. There are crates (like crossbeam
) that provide similar functionality.
Error Handling
- Rust’s
Result
type is often used for error handling in concurrent code. - Go typically uses multiple return values for error handling.
Rust’s Approach to Concurrency
Rust’s approach to concurrency is designed to leverage its ownership and type system to prevent common concurrency bugs at compile time. This can make concurrent Rust code more verbose than Go, but it provides stronger safety guarantees.
Atomic Reference Counter (Arc)
Let’s copy one of the code snippets above for further discussion:
use std::sync::{Arc, Mutex};
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());
}
Here, counter
is an Arc<Mutex<i32>>
:
Arc
Arc
(Atomic Reference Counted) allows multiple ownership across threads.Mutex
(mutual exclusion) ensures only one thread can access the data at a time.
counter.lock()
counter.lock()
attempts to acquire the lock on theMutex
.- It returns a
Result<MutexGuard<i32>, PoisonError<MutexGuard<i32>>>
. - If successful, it gives you a
MutexGuard<i32>
, which acts like a smart pointer to the protected data. - If the mutex is already locked, the thread will block until it can acquire the lock.
unwrap()
.unwrap()
extracts theMutexGuard<i32>
from theResult
if the lock was successfully acquired.- If the lock failed (e.g., due to poison), it will panic.
So, counter.lock().unwrap()
; does the following:
- Tries to lock the mutex.
- If successful, gives you access to the protected data.
- If it fails (which is rare and usually indicates a serious problem like a panic in another thread while holding the lock), it will panic.
Here’s a more detailed example:
use std::sync::{Arc, Mutex};
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;
// MutexGuard automatically unlocks when it goes out of scope
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Key points to remember:
lock()
is used to safely access the shared data.unwrap()
is used here for simplicity, but in production code, you might want to handle potential errors more gracefully.- The
MutexGuard
returned bylock()
automatically releases the lock when it goes out of scope.
This pattern ensures that only one thread can modify the counter at a time, preventing data races and other concurrency issues.
More on unwrap()
unwrap()
is a method available on Result<T, E>
and Option<T>
types.
It’s used to extract the value inside a Result
or Option
.
For Result<T, E>
:
- If the
Result
isOk(value)
,unwrap()
returns the value. - If the
Result
isErr(e)
,unwrap()
will panic.
For Option<T>
:
- If the
Option
isSome(value)
,unwrap()
returns the value. - If the
Option
isNone
,unwrap()
will panic.
Here’s an example:
rust main() {
// With Result
let result: Result<i32, &str> = Ok(5);
let value = result.unwrap(); // This is fine, value = 5
let error_result: Result<i32, &str> = Err("oops");
// let value = error_result.unwrap(); // This would panic!
// With Option
let option: Option<i32> = Some(10);
let value = option.unwrap(); // This is fine, value = 10
let none_option: Option<i32> = None;
// let value = none_option.unwrap(); // This would panic!
}
Alternatives to unwrap()
expect()
works similar tounwrap()
, but allows you to specify an error message.- You can use
match
foor more fine-grained control over both success and error cases. if let
: For concise handling when you only care about the success case.?
operator: For propagating errors in functions that returnResult
.
Here’s an example of more robust error handling:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("Division by zero".to_string())
} else {
Ok(a / b)
}
}
fn main() {
match divide(10, 2) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
// Or using if let
if let Ok(result) = divide(10, 0) {
println!("Result: {}", result);
} else {
println!("Division failed");
}
}
In summary, while unwrap()
is a convenient way to quickly get to the value in a Result
or Option
, it’s generally better to use more explicit error handling in production code to avoid unexpected panics and provide better error information.
Mutexes
Let’s revisit the example with Mutex
:
use std::sync::{Arc, Mutex};
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;
// MutexGuard automatically unlocks when it goes out of scope
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Here are some key points in the above example to consider:
Mutex Locking
- When you call
counter.lock()
, it returns aMutexGuard
wrapped in aResult
. MutexGuard
is a smart pointer that provides exclusive access to the data inside theMutex
.- While a
MutexGuard
exists, no other thread can acquire the lock on this Mutex.
Exclusive Access
- Once you’ve called
unwrap()
and have theMutexGuard
, you have exclusive access to the data. - Other threads trying to lock the same Mutex will block until your
MutexGuard
is dropped.
Borrow Checking
- The borrow checker ensures that you use the
MutexGuard
correctly within your thread. - It prevents you from keeping the lock longer than necessary or trying to acquire it multiple times simultaneously in the same thread.
Lock Release
The MutexGuard
automatically releases the lock when it goes out of scope (i.e., at the end of the block or function where it was created).
Annotated Example
Here’s an expanded example to illustrate:
use std::sync::{Arc, Mutex};
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();
// At this point, we have exclusive access to the data
*num += 1;
// The lock is released here when `num` goes out of scope
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
Let’s review the key points:
- The
Mutex
ensures that only one thread can access the data at a time. - The borrow checker ensures that within a single thread, you’re using the
MutexGuard
correctly. - The combination of Mutex and Rust’s ownership system provides thread-safe access to shared mutable state.
The join()
Method
join()
waits for the thread to finish and returns aResult
.- Its signature is:
fn join(self) -> Result<T, Box<dyn Any + Send + 'static>>
.
The Result allows for handling of two scenarios:
- The thread completed successfully (Ok variant)
- The thread panicked (Err variant)
Modules and Crates
Modules and crates are fundamental to organizing Rust code, especially as projects grow larger.
Modules
- Modules are used to organize code within a crate.
- They can be defined in a single file or in separate files.
Crates
- A crate is the smallest amount of code that the Rust compiler considers at a time.
- It can be a binary crate (an executable) or a library crate.
Let’s create a simple project to demonstrate:
cargo new my_project
cd my_project
Now, let’s structure our project:
Here is the project file structure:
my_project/
├── Cargo.toml
└── src/
├── main.rs
├── lib.rs
└── models/
├── mod.rs
├── user.rs
└── product.rs
Defining Modules
In src/lib.rs
// Declare the models module
pub mod models;
// You can also define modules inline
pub mod utils {
pub fn helper_function() {
println!("I'm a helper!");
}
}
In src/models/mod.rs
:
// Declare submodules
pub mod user;
pub mod product;
In src/models/user.rs
:
pub struct User {
pub name: String,
pub age: u32,
}
impl User {
pub fn new(name: String, age: u32) -> Self {
User { name, age }
}
}
In src/models/product.rs
:
pub struct Product {
pub name: String,
pub price: f64,
}
Using Modules
In src/main.rs
:
// Import the library crate
use my_project::models::{user::User, product::Product};
use my_project::utils::helper_function;
fn main() {
let user = User::new("Alice".to_string(), 30);
println!("User: {} is {} years old", user.name, user.age);
let product = Product { name: "Book".to_string(), price: 29.99 };
println!("Product: {} costs ${}", product.name, product.price);
helper_function();
}
Visibility
- Items in modules are private by default.
- Use
pub
keyword to make items public. - You can use
pub(crate)
to make items visible only within the current crate.
The use
Keyword
- Brings items into scope.
- Can be used with aliases:
use std::io::Result as IoResult;
The mod
Keyword
- Declares a module.
- Can be used to create inline modules or to load module contents from another file.
Crate Root
src/main.rs
is the crate root of a binary crate.src/lib.rs
is the crate root of a library crate.
So, src/main.rs
and src/lib.rs
have special status in Rust projects. Rust and Cargo automatically recognize these files without needing explicit module declarations.
Key Points
- Use modules to organize related code together.
- The module hierarchy doesn’t need to match your file system structure, but it often does for clarity.
- You can have both a binary and a library crate in the same project.
- Use
cargo build
to compile andcargo run
to run your project.
Declaring Modules
File Approach
If you declare pub mod models;
, for example, Rust will first look for a file named models.rs
in the same directory as the file where you declared the module.
Directory Approach
If models.rs
doesn’t exist, Rust will look for a directory named models
with a file named mod.rs
inside it.
You don’t need both models.rs and a models/ directory; Rust will use one or the other. If both exist, Rust will use models.rs
and ignore the models/
directory.
The choice between file and directory approach often depends on the size and complexity of your module.
As your project grows, you can easily switch from the file approach to the directory approach without changing how other parts of your code import the module.
Nested Modules
You can define modules inside other modules.
This creates a hierarchy that can help organize complex code structures.
- You can either inline nested modules;
- Or use separate files for nested modules.
Nested modules provide a way to create a logical hierarchy in your code, which is especially useful for large projects. They allow you to group related functionality together while maintaining clear boundaries between different parts of your codebase.
Smart Pointers
Box Smart pointers are an important concept in Rust, providing additional functionality beyond regular references. Let’s dive into Here’s an example: In this code, The List enum is defined with two variants: In the The Here’s an example: Here’s an example: It’s important to note that Also, The actual data is kept alive as long as at least one Let’s break down our example a bit more: Here is what’s happening: Also note that we can’t move the original Arc into multiple threads: Each thread needs its own Arc pointing to the shared data. Other key points: Here’s how you can implement a shared, mutable counter using Arc and Mutex: Use when you have a large amount of data you want to transfer ownership of without copying: Use it: Use it: Smart pointers in Rust provide powerful tools for managing memory and ownership in various scenarios, allowing you to build complex data structures and manage shared state effectively. We’ll cover For simple counters, you might consider using atomic types, which can be more efficient: This approach using Interior Mutability is the ability to mutate data even when there are immutable references to that data. It’s a way to bend Rust’s strict borrowing rules in a controlled, safe manner. This has no runtime cost, but limited to It enforces borrowing rules at runtime instead of compile-time. Here’s an example combining This pattern allows for shared mutable state, but be cautious as it can lead to runtime panics if not used correctly. Macros are a powerful feature in Rust that allow you to write code that writes other code, which is known as metaprogramming. There are two main types of macros in Rust: Let’s start with Declarative Macros: Here’s an example: Now, let’s look at Procedural Macros: These are more powerful and flexible than declarative macros They come in three flavors: Function-like macros Derive macros Attribute macros They operate on the [abstract syntax tree (AST)] of Rust code Here is an example of a simple function-like procedural macro: First, in a separate crate (typically with a name like Then, in your main crate: This will print: Here are some important points of procedural macros: Here’s an example of a more complex declarative macro: This macro creates a vector of strings from a variety of input types. Unsafe Rust is a way to tell the Rust compiler “I know what I’m doing” for operations it can’t verify as safe. It allows you to perform operations that might violate Rust’s safety guarantees. Unsafe Rust is typically used to: Here are certain things that you can do with unsafe Rust: Since you are bypassing Rust’s safety checks, you have certain responsibilities when using unsafe code: Here’s an example of unsafe Rust: In this example: Common use cases for unsafe: Here are things to keep in ming when using unsafe Rust: It’s important to note that “unsafe” doesn’t mean the code is necessarily dangerous; it means the compiler can’t verify its safety. Well-written unsafe code can be just as safe as safe Rust, but it requires more care and expertise to get right. Here’s an example: Here’s an example: In tests, Associated types are a way to associate a type with a trait. They’re used when a trait needs to use a type, but the specific type isn’t known until the trait is implemented. Here’s an example: You can use associated types: Default type parameters allow you to specify a default type for a generic type parameter. This can be useful for reducing boilerplate when a specific type is commonly used. Here’s an example: You’d use default types parameters: This syntax allows you to disambiguate between multiple implementations of a trait for a type, or to call a specific trait method. Here’s an example: You can use fully-qualified syntax: These features contribute to Rust’s powerful type system: The newtype pattern involves creating a new type, usually to wrap an existing type. This is done using a tuple struct with a single field. Here’s an example: Here are some use cases for ths pattern: Type aliases create a new name for an existing type. They don’t create a new type; they’re just a shorthand. Here’s an example: Here are some use cases for type aliases: The never type, denoted by Here’s an example: Here are when you might use the never type: Let’s look at a more complex example combining these concepts: In this example above: These features contribute to Rust’s type system by: Let see never type in some more action: Here are some key points: The “never type” is more akin to concepts like “bottom” in type theory or “never type” is a way to represent computations that don’t complete normally, whether due to infinite loops, program termination, or panics. In Rust, which doesn’t have Function pointers allow you to pass functions as arguments or store them in structs. They have a concrete size known at compile time. Here’s the syntax: Here’s an example that uses function pointers: Here are certain use cases for function pointers: Closures are anonymous functions that can capture their environment. Returning closures is a bit trickier because their size isn’t known at compile time, so we need to use dynamic dispatch. Here’s the syntax: Here is an example that returns closures: Here are some use cases for returning closures: Now let’s look at a more complex example combining several concepts: Here are some insights and considerations: These features allow for powerful functional programming patterns in Rust, enabling you to write more flexible and reusable code. Here is how you can use async/await in Rust: Here is an example of concurrent operations: Here are some key points about async/await: Attribute macros allow you to create custom attributes that can be applied to items like functions, structs, or modules. They can generate, modify, or replace code. The Here are some other common uses: Compiling Rust to WebAssembly (Wasm) is indeed an exciting topic. Rust is one of the best-supported languages for WebAssembly compilation, thanks to its low-level control and lack of runtime. Let’s see how we can compile Rust into Web Assembly: First add the WebAssembly target: Then install Then create a new library project: Now, modify Write your rust code: Build the WebAssembly module: Then use in HTML/JavaScript: Create an Use any kind of local statis server to serve the files. Here’s what happens in this process: Here’s a more complex example using web APIs: This example demonstrates how to manipulate the DOM using Rust compiled to WebAssembly. Here are certain areas where using Rust for WebAssembly shines: WebAssembly is about bringing near-native performance to the web for computationally intensive tasks. Rust’s safety guarantees and performance make it an excellent choice for WebAssembly. The real power of Wasm comes when you combine WebAssembly’s performance with JavaScript’s flexibility. You can use WebAssembly when: In the generated code Here’s a conceptual view of how it works: The JavaScript wrapper is doing several important things: Embedded Rust programming is an exciting field that allows you to use Rust’s safety and performance features for microcontroller-based systems. To program embedded Rust, first, you’ll need to install some additional tools: Here are some key concepts that you need to be aware of: Here is an example: Let’s create a simple LED blink program for an First, create a new project: Then update Replace Now let’s build the project: Build for release: Here are some important differences from standard Rust: Embedded Rust supports debugging through GDB and OpenOCD. You can use For more complex applications, you might use an RTOS (realtime operating system). The RTIC (Real-Time Interrupt-driven Concurrency) framework is popular in the Rust embedded community. Here are some other common creates for embedded development: Embedded Rust combines the safety and expressiveness of Rust with the ability to write low-level code for microcontrollers, making it an excellent choice for developing reliable embedded systems. Here are some key commands: Cargo is Rust’s package manager and build system. It’s installed alongside Rust and handles: Here are some key commands: The Rust compiler. You typically interact with it through Cargo, but you can use it directly too: The official Rust code formatter. It ensures consistent code style across Rust projects. A collection of lints to catch common mistakes and improve your Rust code. A language server implementation for Rust, providing IDE-like features to code editors. These tools (and some others) form the core of the Rust development experience, providing a robust, integrated ecosystem for writing, testing, and deploying Rust code. Unit tests in Rust are typically written in the same file as the code they’re testing. Integration tests are external to your library and use it as any other code would. They’re placed in the tests directory Each file in the tests directory is compiled as a separate crate Here is an example: Here is how you run tests: Here’s an example: Here’s an example of a more organized test structure: Rust doesn’t have a built-in mocking framework, but crates like mockall can be used for mocking. You can use tools like tarpaulin to measure test coverage: Here is how you can publish your Rust crate: First, ensure your Cargo.toml file is properly configured: Then, create an Account on crates.io, if you haven’t already done so. Then login to crates.io via Cargo Before publishing, it’s good to check your package: This command will create a When you’re ready to publish, execute the following command: This will upload your crate to crates.io. To publish a new version, update the Rust provides atomic types in the Here are basic atomic types: Here’s a sample code that uses atomics: Rust’s atomics allow you to specify memory ordering constraints: Ordering is crucial for implementing synchronization primitives and lock-free data structures. It allows one thread to safely communicate changes to shared data to other threads. A common pattern is: Think of The This means all operations before the The “ Without Lets’ see this with a multithreaded example: In this code, we might expect that if thread 2 sees Here’s some reasons why this might happen: By using appropriate memory orderings, you’re essentially saying to the compiler (and the CPU): “Even though you can’t see why from a single-thread perspective, these operations need to stay in this order for the program to work correctly across all threads.” To fix the random behavior that might happen in the code above, we would use Here’s a more practical example of a lock-free stack implementation that leverages memory ordering: Remember, while lock-free structures can offer great performance benefits, they are also much more challenging to implement correctly. Always profile and benchmark to ensure they are providing real benefits in your specific use case. Here is a non-exhaustive list of tools, resources, references, libraries, and concepts mentioned in this article: This was a deep dive into a lot of features and paradigms in Rust, while occasionally comparing it with other languages such as C++ and Go. Rust is a powerful language that offers a unique blend of performance, safety, and expressiveness. It’s a great choice for systems programming, web development, and embedded programming. Having said that, it’s impossible to get a feeling of it without actually programming something in Rust. So, I encourage you to start coding in Rust, copy the examples you see here, and try to modify and make sense of them. The more you code in Rust, the more you’ll appreciate its design choices and the benefits it offers. Also the more you code in rust, the more the relatively weirder parts of it will start to make sense. Until next time… May the source be with you 🦄.Box<T>
, Rc<T>
, and Arc<T>
:Box<T>
Box<T>
is a smart pointer for heap allocation.fn main() {
let b = Box::new(5);
println!("b = {}", b);
// Recursive type example
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1,
Box::new(List::Cons(2,
Box::new(List::Nil))));
}
Cons
is a variant of the List
enum that represents a node in a recursively defined linked list.Cons(i32, Box<List>)
: This variant represents a non-empty list node.Nil
: This variant represents the end of the list.Cons
variant:i32
) is the value stored in the current node.Box<List>
) is a boxed pointer to the next node in the list.Box
is used here because Rust needs to know the size of all types at compile time. Without Box
, the List
type would have an infinite size due to its recursive nature. Box
provides a fixed-size pointer to heap-allocated data, solving this issue.Rc<T>
(Reference Counted)Rc<T>
allows multiple ownership of the same data.use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("Hello"));
let b = Rc::clone(&a);
let c = Rc::clone(&a);
println!("Reference count: {}", Rc::strong_count(&a));
// Prints: Reference count: 3
println!("{}, {}, {}", a, b, c);
}
Arc<T>
(Atomically Reference Counted)Arc<T>
is similar to Rc<T>
but safe to use in concurrent situations.Rc<T>
Rc<T>
due to the overhead of atomic operationsuse std::sync::Arc;
use std::thread;
fn main() {
let s = Arc::new(String::from("Hello, threads!"));
let mut handles = vec![];
for _ in 0..3 {
let s_clone = Arc::clone(&s);
let handle = thread::spawn(move || {
println!("{}", s_clone);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
Arc::clone(&s)
doesn’t create a borrow, but a new owned reference to the same data.Arc
keeps track of how many clones exist.Arc
is dropped and the count reaches zero.Arc
allows the data to be safely shared between threads because it uses atomic operations for its reference counting.Arc
pointing to it exists. When the last Arc
is dropped, the data is automatically deallocated.Arc
provides shared, immutable access to its contents.Arc
with something like Mutex
or RwLock
.use std::sync::Arc;
use std::thread;
fn main() {
let s = Arc::new(String::from("Hello, threads!"));
let mut handles = vec![];
for _ in 0..3 {
let s_clone = Arc::clone(&s);
let handle = thread::spawn(move || {
println!("{}", s_clone);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
Arc::new(String::from("Hello, threads!"))
creates an Arc
containing the string.Arc::clone(&s)
creates a new Arc
pointing to the same data, incrementing the reference count.thread::spawn(move || { ... })
moves the s_clone
into the new thread, giving that thread ownership of this Arc
.Arc
provides thread-safe shared access.Arc
is dropped, decrementing the reference count.Arc
in the main thread keeps the data alive until all threads have finished.Arc
is dropped, the string is deallocated.Arc
is about shared ownership, not borrowing.Arc
itself only provides immutable access; for mutable access, you’d need to combine it with synchronization primitives.use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Create a shared, mutable counter
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!("Final count: {}", *counter.lock().unwrap());
}
Comparison and Use Cases
Box<T>
Rc<T>
Arc<T>
RefCell<T>
RefCell<T>
is often used with Rc<T>
to allow mutable borrowing:use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let data = Rc::new(RefCell::new(5));
let a = Rc::clone(&data);
let b = Rc::clone(&data);
*a.borrow_mut() += 1;
*b.borrow_mut() *= 2;
println!("data: {:?}", data); // Prints: data: RefCell { value: 12 }
}
RefCell<T>
:Rc<T>
to create mutable, shared data structuresCell<T>
and RefCell<T>
in more detail in Interior Mutability section that follows.Atomic Types
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", counter.load(Ordering::SeqCst));
}
AtomicUsize
is often more efficient for simple operations like counters, as it doesn’t require locking.Interior Mutability
Cell<T>
Cell<T>
provides interior mutability for types that implement Copy
.Copy
types.use std::cell::Cell;
struct Counter {
value: Cell<i32>,
}
impl Counter {
fn new(value: i32) -> Self {
Counter { value: Cell::new(value) }
}
fn increment(&self) {
self.value.set(self.value.get() + 1);
}
fn get(&self) -> i32 {
self.value.get()
}
}
fn main() {
let counter = Counter::new(0);
counter.increment();
counter.increment();
println!("Count: {}", counter.get()); // Prints: Count: 2
}
Cell<T>
:Copy
types (like i32
, bool
, etc.)get()
and set()
methodsRefCell<T>
RefCell<T>
provides interior mutability for any type.use std::cell::RefCell;
struct Logger {
logs: RefCell<Vec<String>>,
}
impl Logger {
fn new() -> Self {
Logger { logs: RefCell::new(Vec::new()) }
}
fn add_log(&self, message: &str) {
self.logs.borrow_mut().push(message.to_string());
}
fn logs(&self) -> Vec<String> {
self.logs.borrow().clone()
}
}
fn main() {
let logger = Logger::new();
logger.add_log("First log");
logger.add_log("Second log");
println!("Logs: {:?}", logger.logs());
}
RefCell<T>
:T
borrow()
and borrow_mut()
methodsWhen to Use
Cell<T>
and RefCell<T>
Cell<T>
for simple types that implement Copy
when you need interior mutability.RefCell<T>
when you need interior mutability for non-Copy types or when you need more complex borrowing patterns.Safety and Performance or
Cell<T>
and RefCell<T>
Cell<T>
has no runtime cost but is limited to Copy
types.RefCell<T>
has a small runtime cost but can be used with any type.RefCell<T>
can panic if borrowing rules are violated at runtime.Mutex
or RwLock
.Common Use Cases for Interior Mutability
RefCell
with Rc
for a shared, mutable data structure:use std::cell::RefCell;
use std::rc::Rc;
struct SharedData {
value: RefCell<i32>,
}
fn main() {
let data = Rc::new(SharedData { value: RefCell::new(1) });
let data_clone = Rc::clone(&data);
// Modify the value
*data.value.borrow_mut() += 10;
// Read the value from the clone
println!("Value: {}",
*data_clone.value.borrow()); // Prints: Value: 11
}
Macros
Declarative Macros (macro_rules!)
macro_rules!
syntaxmacro_rules! say_hello {
() => {
println!("Hello!");
};
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
say_hello!(); // Prints: Hello!
say_hello!("Rust"); // Prints: Hello, Rust!
}
Procedural Macros
my_macro
):use proc_macro::TokenStream;
use quote::quote;
use syn;
#[proc_macro]
pub fn make_answer(_item: TokenStream) -> TokenStream {
let answer = 42;
let expanded = quote! {
println!("The answer is: {}", #answer);
};
expanded.into()
}
use my_macro::make_answer;
fn main() {
make_answer!();
}
"The answer is: 42"
Differences between Macros and Functions
Common Use Cases for Macros:
macro_rules! vec_of_strings {
($($x:expr),*) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x.to_string());
)*
temp_vec
}
};
}
fn main() {
let v = vec_of_strings![1, "hello", 3.14, true];
println!("{:?}", v);
// Prints: ["1", "hello", "3.14", "true"]
}
Unsafe Rust
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 is: {}", *r1);
*r2 += 1;
println!("r2 is: {}", *r2);
}
}
*const i32
and *mut i32
)debug_assert!
to check invariants in debug buildsdebug_assert!
and assert!
assert!
is a macro used for debugging and testing purposes. It allows you to check if a boolean condition is true
and panics if the condition is false
.debug_assert!
is a conditional compilation macro for assertions Similar to assert!, but only enabled in debug builds.assert!
rustCopyfn divide(a: i32, b: i32) -> i32 {
debug_assert!(b != 0, "Divisor must not be zero!");
a / b
}
fn main() {
println!("10 / 2 = {}", divide(10, 2));
// Uncomment the next line to see the debug assertion in action
// println!("10 / 0 = {}", divide(10, 0));
}
Build Profiles
cargo build
or cargo run
cargo build --release
or cargo run --release
assert!
in Testassert!
is commonly used in tests to check conditions and ensure correctness.mod tests {
use super::*;
#[test]
fn test_calculate_average() {
let nums = vec![1.0, 2.0, 3.0];
assert_eq!(calculate_average(&nums), 2.0);
}
#[test]
#[should_panic(expected = "Cannot calculate average of an empty slice")]
fn test_calculate_average_empty() {
let empty: Vec<f64> = vec![];
calculate_average(&empty);
}
}
assert!
and its variants are used extensively.assert!
In Production CodeWhen to Use
assert!
in Production CodeWhen Not to Use
assert!
in Production CodeResult
instead).Associated Types
trait Container {
type Item;
fn add(&mut self, item: Self::Item);
fn get(&self) -> Option<&Self::Item>;
}
struct VecContainer<T>(Vec<T>);
impl<T> Container for VecContainer<T> {
type Item = T;
fn add(&mut self, item: Self::Item) {
self.0.push(item);
}
fn get(&self) -> Option<&Self::Item> {
self.0.last()
}
}
Default Type Parameters
use std::ops::Add;
#[derive(Debug, PartialEq)]
struct Point<T = f64> {
x: T,
y: T,
}
impl<T: Add<Output = T>> Add for Point<T> {
type Output = Self;
fn add(self, other: Self) -> Self::Output {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = Point { x: 3.0, y: 4.0 };
let p3 = p1 + p2;
assert_eq!(p3, Point { x: 4.0, y: 6.0 });
}
Fully Qualified Syntax
trait Animal {
fn make_sound(&self);
}
trait Cat {
fn make_sound(&self);
}
struct MyCat;
impl Animal for MyCat {
fn make_sound(&self) {
println!("Animal sound");
}
}
impl Cat for MyCat {
fn make_sound(&self) {
println!("Meow");
}
}
fn main() {
let cat = MyCat;
// cat.make_sound(); // Ambiguous, won't compile
// Fully qualified syntax
Animal::make_sound(&cat); // Prints: Animal sound
Cat::make_sound(&cat); // Prints: Meow
// Alternative syntax
<MyCat as Animal>::make_sound(&cat); // Prints: Animal sound
<MyCat as Cat>::make_sound(&cat); // Prints: Meow
}
Newtype Pattern
struct Meters(f64);
struct Kilometers(f64);
impl Meters {
fn to_kilometers(&self) -> Kilometers {
Kilometers(self.0 / 1000.0)
}
}
fn main() {
let distance = Meters(5000.0);
println!("Distance in km: {}", distance.to_kilometers().0);
}
Type Aliases
type Kilometers = f64;
fn distance_to_mars(speed: Kilometers) -> Kilometers {
225.0 * 1_000_000.0 / speed
}
fn main() {
let speed = 100.0; // km/h
println!("Time to Mars: {} hours", distance_to_mars(speed));
}
Never Type (
!
)!
, represents a type that can never be instantiated. It’s used for functions that never return.fn exit() -> ! {
std::process::exit(0);
}
fn main() {
let x: i32 = loop {
if some_condition() {
break 5;
} else if another_condition() {
exit(); // This function never returns
}
};
println!("x is {}", x);
}
fn some_condition() -> bool { true }
fn another_condition() -> bool { false }
panic!
or exit
)use std::ops::Add;
// Newtype
struct Money(u32);
// Type alias
type Result<T> = std::result::Result<T, String>;
// Function that returns the never type
fn transaction_error(msg: &str) -> ! {
panic!("Transaction Error: {}", msg);
}
impl Add for Money {
type Output = Result<Money>;
fn add(self, other: Self) -> Self::Output {
let sum = self.0.checked_add(other.0)
.ok_or_else(|| "Overflow occurred".to_string())?;
Ok(Money(sum))
}
}
fn process_transaction(a: Money, b: Money) -> Result<Money> {
match a + b {
Ok(sum) => Ok(sum),
Err(e) => transaction_error(&e), // This arm has type `!`
}
}
fn main() -> Result<()> {
let wallet1 = Money(100);
let wallet2 = Money(50);
let total = process_transaction(wallet1, wallet2)?;
println!("Total money: {}", total.0);
Ok(())
}
Money
is a newtype, providing type safety for monetary values.Result<T>
is a type alias, simplifying error handling syntax.transaction_error
returns !
, indicating it never returns normally.// A function that returns !
fn forever() -> ! {
loop {
println!("I'm running forever!");
}
}
// ! in a match expression
fn check_number(x: i32) -> &'static str {
match x {
0 => "zero",
1 => "one",
_ => panic!("Unknown number"), // This arm has type !
}
}
// Using ! for diverging functions
fn exit(code: i32) -> ! {
std::process::exit(code)
}
fn main() {
let x: u32 = loop {
break 42;
};
println!("x: {}", x); // This works because ! can coerce to u32
// Never type can coerce to any type
// This would run forever:
// forever();
println!("Number: {}", check_number(1)); // Prints: Number: one
// This would panic:
// check_number(2);
// This would exit the program:
// exit(0);
}
!
is used for functions or expressions that don’t return normally.Never Type versus Null
void
in some other languages. It is not null
, but more like “indeterminate”.Null
, the closest equivalent to Null would be Option<T>
with a None
value. This is very different from !
, as Option<T>
is about representing the presence or absence of a value, while !
is about representing the absence of a return.Function Pointers
fn(params) -> return_type
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn subtract(a: i32, b: i32) -> i32 {
a - b
}
fn do_operation(f: fn(i32, i32) -> i32, x: i32, y: i32) -> i32 {
f(x, y)
}
fn main() {
println!("Add: {}", do_operation(add, 5, 3));
// Prints: Add: 8
println!("Subtract: {}", do_operation(subtract, 5, 3));
// Prints: Subtract: 2
}
Returning Closures
Box<dyn Fn(params) -> return_type>
fn create_adder(x: i32) -> Box<dyn Fn(i32) -> i32> {
Box::new(move |y| x + y)
}
fn main() {
let add_5 = create_adder(5);
println!("5 + 10 = {}", add_5(10)); // Prints: 5 + 10 = 15
}
fn math_operation(op: &str) -> Box<dyn Fn(i32, i32) -> i32> {
match op {
"add" => Box::new(|x, y| x + y),
"subtract" => Box::new(|x, y| x - y),
"multiply" => Box::new(|x, y| x * y),
"divide" => Box::new(|x, y| x / y),
_ => Box::new(|x, _| x),
// ^ Identity function for unknown operations
}
}
fn apply_operations(operations:
&[fn(i32, i32) -> i32], x: i32, y: i32) -> Vec<i32> {
operations.iter().map(|op| op(x, y)).collect()
}
fn main() {
let add = math_operation("add");
println!("10 + 5 = {}", add(10, 5)); // Prints: 10 + 5 = 15
let operations = [
math_operation("add") as fn(i32, i32) -> i32,
math_operation("multiply") as fn(i32, i32) -> i32,
];
let results = apply_operations(&operations, 10, 5);
println!("Results: {:?}", results); // Prints: Results: [15, 50]
}
Box<dyn Fn(...)>
can capture their environment but require dynamic dispatch.Box
and dynamic dispatch due to their unknown size.'static
lifetime is often required for returned closures to ensure they live long enough.Async/Await
// Tokio is a popular asynchronous runtime for Rust:
use tokio;
async fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
// Simulating an async operation
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(format!("Data from {}", url))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let result = fetch_data("https://example.com").await?;
println!("Fetched: {}", result);
Ok(())
}
use tokio;
use futures::future::join_all;
async fn fetch_multiple(urls: &[&str]) ->
Vec<Result<String, Box<dyn std::error::Error>>> {
let fetches = urls.iter().map(|&url| fetch_data(url));
join_all(fetches).await
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let urls = vec!["https://example.com", "https://rust-lang.org"];
let results = fetch_multiple(&urls).await;
for result in results {
match result {
Ok(data) => println!("Fetched: {}", data),
Err(e) => println!("Error: {}", e),
}
}
Ok(())
}
Future
, which must be polled to make progress.Future
s.tokio
, async-std
, etc.).#[tokio::main]
attribute sets up the tokio
runtime for the main
function.Result<T, E>
for error handling.?
operator is often used for propagating errors in async contexts.Future
s in Rust often need to be pinned in memory for safety reasons.Stream
trait, similar to JavaScript’s async iterators.#[tokio::main]
#[tokio::main]
is an attribute macro that sets up the tokio
runtime for the main
function. It’s a convenient way to run asynchronous code in Rust.#[tokio::main]
is just one example of this broader concept.#[test]
#[derive(Debug, Clone)]
#[get("/")]
in Rocket or Actix#[tokio::main]
, #[async_std::main]
#[serde(rename = "my_field")]
Web Assembly
rustup target add wasm32-unknown-unknown
wasm-pack
:cargo install wasm-pack
cargo new --lib hello-wasm
cd hello-wasm
Cargo.toml
:[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
wasm-pack build --target web
index.html
file:<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Rust WebAssembly Demo</title>
</head>
<body>
<script type="module">
import init, { greet } from './pkg/hello_wasm.js';
async function run() {
await init();
const result = greet('WebAssembly');
document.body.textContent = result;
}
run();
</script>
</body>
</html>
python3 -m http.server
wasm-bindgen
is crucial for creating JavaScript bindings for your Rust functions.#[wasm_bindgen]
attribute exposes Rust functions to JavaScript.wasm-pack
handles much of the complexity of building and packaging Wasm modules..wasm
file is not used directly; instead, use the generated JavaScript wrapper.use wasm_bindgen::prelude::*;
use web_sys::{Document, HtmlElement};
#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let body = document.body().expect("document should have a body");
let val = document.create_element("p")?;
val.set_inner_html("Hello from Rust!");
body.append_child(&val)?;
Ok(())
}
Why Use Rust for WebAssembly
Wasm JavaScript Wrapper
.wasm
file contains the compiled WebAssembly module. It is a binary file.my_wasm_module.js
file is a JavaScript wrapper that provides a friendly interface to the WebAssembly module.[Your JavaScript Code]
<-> [JS Wrapper (.js)]
<-> [WebAssembly Module (.wasm)]
.wasm
module.Embedded Programming In Rust
rustup target add thumbv7em-none-eabihf
# ^ For ARM Cortex-M4F and Cortex-M7F
cargo install cargo-embed cargo-binutils
#![no_std]
attribute as the full standard library isn’t available.STM32F3DISCOVERY
board (which uses an ARM Cortex-M4F).cargo new --bin led_blink
cd led_blink
Cargo.toml
:[package]
name = "led_blink"
version = "0.1.0"
edition = "2021"
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
panic-halt = "0.2"
stm32f3xx-hal = { version = "0.9", features = ["stm32f303xc", "rt"] }
[[bin]]
name = "led_blink"
test = false
bench = false
src/main.rs
with the following:#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
use stm32f3xx_hal::{pac, prelude::*};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let mut rcc = dp.RCC.constrain();
let mut gpioe = dp.GPIOE.split(&mut rcc.ahb);
let mut led = gpioe
.pe13
.into_push_pull_output(
&mut gpioe.moder, &mut gpioe.otyper);
loop {
led.set_high().unwrap();
cortex_m::asm::delay(8_000_000);
led.set_low().unwrap();
cortex_m::asm::delay(8_000_000);
}
}
cargo build --release
cargo embed --release
#![no_std]
: We can’t use the standard library.#![no_main]
: We define our own entry point with #[entry]
.-> !
: The main function never returns (it’s an infinite loop).cargo run
with the appropriate runner configuration to start a debug session.stm32f3xx-hal
in our example).Rustup
rustup
is the official Rust toolchain installer and version management tool. It allows you to:rustup update # Update Rust
rustup default stable # Set the default toolchain
rustup target add wasm32-unknown-unknown # Add a compilation target
rustup component add rustfmt # Add a component
Cargo
cargo new my_project # Create a new project
cargo build # Compile the project
cargo run # Run the project
cargo test # Run tests
cargo doc --open # Generate and open documentation
rustc
rustc main.rs # Compile a single file
rustfmt
Clippy
cargo clippy # Run clippy on your project
rust-analyzer
Testing Rust
Unit Tests
#[test]
#[cfg(test)]
// In src/lib.rs or src/main.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
}
Integration Tests
// In tests/integration_test.rs
use my_crate;
#[test]
fn test_add_integration() {
assert_eq!(my_crate::add(2, 2), 4);
}
Organization
Running Tests
cargo test # Run all tests
cargo test test_name # Run a specific test
cargo test -- --nocapture # Show println! output
Test Attributes
#[should_panic]
: Test should panic#[ignore]
: Skip this test unless specifically requested#[test]
#[should_panic(expected = "divide by zero")]
fn test_divide_by_zero() {
divide(1, 0);
}
#[test]
#[ignore]
fn expensive_test() {
// ...
}
Test Organization Best Practices
// In src/lib.rs
pub mod math {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
#[test]
fn test_subtract() {
assert_eq!(subtract(4, 2), 2);
}
}
}
// In tests/math_integration_tests.rs
use my_crate::math;
#[test]
fn test_add_and_subtract() {
let result = math::add(5, 5);
assert_eq!(math::subtract(result, 5), 5);
}
Mocking
Test Coverage
cargo install cargo-tarpaulin
cargo tarpaulin
Publishing Packages
[package]
name = "your_crate_name"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
edition = "2024"
description = "A short description of your crate"
license = "MIT"
repository = "https://github.com/yourusername/your_crate"
documentation = "https://docs.rs/your_crate_name"
readme = "README.md"
keywords = ["keyword1", "keyword2"]
categories = ["category1", "category2"]
[dependencies]
# Your dependencies here
cargo login
cargo package
.crate
file in the target/package/
directory.cargo publish
version
field in Cargo.toml
and run cargo publish
again.Publishing Best Practices
MAJOR.MINOR.PATCH
) for your crate versions.//!
for module-level docs and ///
for function/struct docs. Your crate’s documentation will automatically be built and published on docs.rs
.README.md
file with basic usage instructions and examples.cargo yank --vers 1.0.1
crates.io
.cargo publish --dry-run
.Atomics in Rust
std::sync::atomic
module. These types allow for lock-free synchronization between threads.AtomicBool
AtomicI32
, AtomicU32
, AtomicI64
, AtomicU64
AtomicUsize
, AtomicIsize
AtomicPtr<T>
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = AtomicUsize::new(0);
counter.fetch_add(1, Ordering::SeqCst);
println!("Counter: {}", counter.load(Ordering::SeqCst));
Memory Ordering
Ordering::Relaxed
: Provides no synchronization or ordering guarantees. It’s the fastest but also the most dangerous to use incorrectly.Ordering::Release
: Ensures that all memory operations before the release operation are visible to other threads that perform an acquire operation on the same variable.Ordering::Acquire
: Ensures that all memory operations after the acquire operation are ordered after the acquire. Or, more concisely, all subsequent operations see the effects of the previous release.Ordering:AcqRel
: Combines the effects of both Acquire and Release orderings.Ordering:SeqCst
: Provides the strongest guarantees, ensuring a total order for all SeqCst
operations across all threads.Release
operation (e.g., store
with Release
ordering)Acquire
operation (e.g., load
with Acquire
ordering), then read the shared data.Happens-before Relationship
Release
as sealing an envelope: Everything you’ve written (previous memory operations) is sealed inside. The Acquire
load
is like opening the envelope, ensuring the recipient sees all its contents.Release
store
establishes a happens-before relationship with the corresponding Acquire
load
.Release
are guaranteed to happen before all operations after the Acquire
in the other thread.Acquire
first, Release
next” pattern typically refers to the consumer-producer relationship:Release
to signal that data is ready.Acquire
to wait for and read that signal.Bringing Balance to the Force
Release
ordering is more expensive than Relaxed
but less expensive than SeqCst
. It provides a good balance between performance and necessary synchronization for this pattern.Why Orderings Matter
Order::Acquire
and other orderings, the CPU might reorder memory operations for optimization purposes. The Acquire
and other orderings prevent this specific type of reordering.load
, the CPU might speculatively execute subsequent reads or writes before the load actually completes. Acquire
ordering prevents this.Acquire
ordering also constrains the compiler, preventing it from moving operations that come after the Acquire
(in program order) to before it.Acquire
synchronizes with Release operations on other cores, ensuring proper visibility of memory operations across cores.use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
static FLAG: AtomicBool = AtomicBool::new(false);
static DATA: AtomicBool = AtomicBool::new(false);
fn main() {
let t1 = thread::spawn(|| {
DATA.store(true, Ordering::Relaxed);
FLAG.store(true, Ordering::Relaxed);
});
let t2 = thread::spawn(|| {
while !FLAG.load(Ordering::Relaxed) {}
assert!(DATA.load(Ordering::Relaxed));
});
t1.join().unwrap();
t2.join().unwrap();
}
FLAG
set to true
, it would always see DATA
as true. However, with Relaxed
ordering, this isn’t guaranteed. The CPU or compiler could reorder the operations in thread 1, or the write to DATA
might not be visible to thread 2 immediately.Release
ordering for the store
s in thread 1 and Acquire
ordering for the load
s in thread 2:FLAG.store(true, Ordering::Release);
// ...
while !FLAG.load(Ordering::Acquire) {}
Lock-Free Programming
use std::sync::atomic::{AtomicPtr, Ordering};
use std::ptr;
struct Node<T> {
data: T,
next: AtomicPtr<Node<T>>,
}
pub struct Stack<T> {
head: AtomicPtr<Node<T>>,
}
impl<T> Stack<T> {
pub fn new() -> Self {
Stack { head: AtomicPtr::new(ptr::null_mut()) }
}
pub fn push(&self, data: T) {
let new_node = Box::into_raw(Box::new(Node {
data,
next: AtomicPtr::new(ptr::null_mut()),
}));
loop {
let old_head = self.head.load(Ordering::Relaxed);
unsafe {
(*new_node).next.store(old_head, Ordering::Relaxed);
}
if self.head.compare_exchange(old_head, new_node,
Ordering::Release, Ordering::Relaxed).is_ok() {
break;
}
}
}
pub fn pop(&self) -> Option<T> {
loop {
let old_head = self.head.load(Ordering::Acquire);
if old_head.is_null() {
return None;
}
let new_head = unsafe {
(*old_head).next.load(Ordering::Relaxed) };
if self.head.compare_exchange(old_head, new_head,
Ordering::Release, Ordering::Relaxed).is_ok() {
let data = unsafe { Box::from_raw(old_head).data };
return Some(data);
}
}
}
}
Ordering Types for
store
Operationsstore
operations can use Relaxed
, Release
, or SeqCst
ordering.Acquire
and AcqRel
orderings are not applicable to store operations.Acquire
ordering is used for load
operations. It ensures that subsequent memory accesses in the same thread cannot be reordered before this load
.Lock-Free Programming Is Not Easy
References and Further Reading
Conclusion