Learn Rust Programming – A Hands-on Guide for Beginners
Rust has quickly become one of the most loved programming languages. The Stack Overflow Developer Survey has ranked it the most loved language for six years straight!
So what‘s all the hype about? In this comprehensive guide, we‘ll walk through everything you need to know to get started with Rust.
Why Learn Rust?
Rust solves pain points present in many other languages:
Memory safety – The compiler guarantees memory safety without a garbage collector. No more null pointer exceptions!
Concurrency – Rust‘s ownership model lays the foundation for fearless concurrency. Easy parallelism and zero-cost abstractions.
Speed – Abstractions over the hardware allow for high performance systems code to be written with ease.
Reliability – The rich type system catches bugs at compile time. Coupled with ownership, this eliminates entire classes of bugs.
With these key strengths, Rust is an ideal choice for systems programming, web servers, DevOps tooling, embedded devices and more. Companies like Microsoft, Amazon, Google and Facebook use Rust for mission critical components.
Now that I‘ve convinced you to give it a try, let‘s get our hands dirty!
Setting Up Rust
Getting setup with Rust is simple with a tool called rustup
. It manages Rust installations and facilitates easy cross-platform development.
To get started, just run this in your terminal:
curl --proto ‘=https‘ --tlsv1.2 -sSf https://sh.rustup.rs | sh
This installs rustup
as well as the latest Rust stable toolchain. You can then view the installed toolchains:
rustup show
Default host: x86_64-unknown-linux-gnu
installed toolchains
--------------------
stable-x86_64-unknown-linux-gnu (default)
With Rust ready to go, let‘s write our first Rust program!
Hello World
Here is the canonical Hello World program in Rust:
fn main() {
println!("Hello World!");
}
Let‘s break down what‘s happening here:
fn main()
declares the main functionprintln!
is a Rust macro that prints text{}
placeholders allow formatted text
Now to run it. Rust projects are managed by the cargo
build tool and package manager. We can initialize a new project by running:
cargo new hello-rust
Created binary (application) `hello-rust` package
The main Rust code goes into src/main.rs
. We can run the project with:
cargo run
Compiling hello-rust v0.1.0 (/hello-rust)
Finished dev [unoptimized + debuginfo] target(s) in 0.50s
Running `target/debug/hello-rust`
Hello World!
And there we have it! Our first Rust program printed Hello World. Now let‘s dive deeper into the language.
Ownership and Borrowing
Managing memory is critical for low-level systems languages. The biggest innovation in Rust is its ownership model.
Each value in Rust is owned by a variable. When the variable goes out of scope, the value will be dropped (freed). Consider this simple example:
fn main() {
let v = vec![1, 2, 3];
println!("The vector has {} elements", v.len());
}
The vector v
is initialized with a value. It is the owner. After we print its length, v
goes out of scope. At this point the vector and its data gets freed – no memory leaks!
This ownership concept ensures memory safety without needing a garbage collector constantly running in the background.
We can also access data without taking full ownership through references:
fn print_vector(v: &Vec<i32>) {
println!("{:?}", v);
}
fn main() {
let v = vec![1, 2, 3];
print_vector(&v);
}
Now print_vector
takes a reference &
to the vector. It can read the data but does not fully own it. The original variable v
retains ownership.
This core concepts enables Rust to guarantee memory safety through its compiler without needing a garbage collector. Pretty cool right!
Basic Syntax
Now that we have seen some basics of ownership and borrowing, let‘s go over the elementary Rust syntax:
Variables
let x = 5; // Immutable by default
let mut y = 10; // Mutable
Functions
fn add(a: i32, b: i32) -> i32 {
a + b
}
Conditionals
let age = 18;
if age >= 21 {
println!("Have a beer! πΊ");
} else {
println!("Have some lemonade instead! π");
}
Loops
let mut n = 1;
while n < 1000 {
n *= 2;
}
for i in 1..11 {
println!("{}", i);
}
This covers basic syntax. Let‘s move on to custom data structures.
Structs and Enums
Like most languages, Rust allows you to define custom data types in the form of structs
and enums
.
// Struct
struct User {
username: String,
email: String,
sign_in_count: u64
}
// Instantiate
let user1 = User {
email: "[email protected]".to_string(),
username: "someusername123".to_string(),
sign_in_count: 1,
};
// Enum
enum Message {
Quit,
ChangeColor(i32, i32, i32),
Move { x: i32, y: i32 },
Write(String),
}
Structs allow related data to be bundled together while enums create custom data types.
These tools allow modeling complex concepts in a readable way.
Error Handling
Most applications will have to deal with errors during runtime: invalid user input, network timeouts, filesystem permissions issues etc.
In Rust, error handling is baked into the type system through Result
and early returns:
enum Error {
IO(std::io::Error),
Message(String),
}
type Result<T> = std::result::Result<T, Error>;
fn read_username() -> Result<String> {
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_owned();
if input.is_empty() {
return Err(Error::Message("Empty input".into()));
}
Ok(input)
}
This shows off a few things:
Result
encodes errors into return types- The
?
operator bubbles up errors - We can easily define custom error enums
This eliminates error handling boilerplate without losing rigor.
Collections
Rust comes with a variety of collection types to store and process data:
Arrays
Fixed size continuous sequence of elements.
let nums: [i32; 3] = [1, 2, 3];
Vectors
Dynamically resizing arrays.
let mut vec = Vec::new();
vec.push(1);
vec.push(2);
Strings
UTF-8 encoded strings.
let s: String = "Hello".to_string();
HashMap
Key-value store.
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
Rust‘s strong type system makes these collection types behave safely. Now let‘s look at concurrency.
Fearless Concurrency
Rust was designed from the ground up for concurrency. Its ownership model lays the foundation for completely lock-free concurrency.
We can spawn native OS threads easily:
use std::thread;
thread::spawn(|| {
println!("Hello from a thread!");
});
Channels facilitate easy communication between threads:
use std::sync::mpsc::channel;
let (tx, rx) = channel();
thread::spawn(move || {
let msg = String::from("message!");
tx.send(msg).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
Rust also has high-level abstractions like async/await that make concurrency approachable:
async fn make_coffee() {
task::sleep(Duration::from_secs(5)).await;
println!("β");
}
#[tokio::main]
async fn main() {
let coffee_future = make_coffee();
println!("Waiting...");
coffee_future.await;
}
With ownership and zero-cost abstractions, Rust makes highly concurrent systems easy and safe!
Testing and Debugging
Rust comes with first-class support testing and debugging our code.
Let‘s write some tests for a function:
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
We can run these tests with cargo test
. This command builds tests into binaries and runs them:
Compiling adder v0.1.0 (/adder)
Finished test [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/main.rs (target/debug/deps/adder-92948b651005e28e)
running 1 test
test tests::test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
For debugging, we can print values with dbg!
:
fn div(a: i32, b: i32) -> i32 {
dbg!(a, b);
a / b
}
There are also great debugger UIs like the rust-analyzer plugin for VSCode.
Rust makes testing and debugging productive with zero runtime costs.
The Crate Ecosystem
Much of Rust‘s productivity comes from its rich ecosystem of packages called "crates". Let‘s look at some useful ones:
reqwest – HTTP client
serde – Serialization framework
tokio – Asynchrounous I/O
Crates can be easily added from crates.io by adding entries under [dependencies]
in Cargo.toml:
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
The ecosystem makes tasks like making HTTP requests, encoding JSON and spinning up web servers easy.
Now that we‘ve covered the basics of the language and ecosystem, let‘s build something!
Project: CLI Calculator
For our first Rust project, we‘ll build a simple calculator program that takes operator and numbers as command line arguments.
Here is how it will work:
$ calc 12 + 5
12 + 5 = 17
Let‘s get started!
First we‘ll initialize a new Cargo project:
cargo new calc
Inside src/main.rs
we‘ll first parse the arguments:
use std::env;
fn main() {
let mut args = env::args();
let a: f32 = args.nth(1).unwrap().parse().unwrap();
let operator = args.nth(0).unwrap().chars().next().unwrap();
let b: f32 = args.nth(0).unwrap().parse().unwrap();
}
This grabs the first 3 arguments, converts them floats/chars and stores them into a
, operator
and b
.
Next we can apply the math operation:
let result = match operator {
‘+‘ => a + b,
‘-‘ => a - b,
‘*‘ => a * b,
‘/‘ => {
if b == 0.0 {
panic!("Cannot divide by 0!");
}
a / b
}
_ => panic!("Invalid operator!")
};
Finally we can print out the formatted result:
println!("{a:?} {operator} {b:?} = {result}");
Our little calculator is now complete!
Rust made this easy and safe – no need to worry about validating numbers or memory management!
I hope you found this whirlwind tour of Rust useful! There are plenty more topics to cover, but this should provide a solid foundation.
If you found this interesting, some next things to try are:
Rust has a thriving community and tons of friendly resources online to help you level up. Welcome to the fray – enjoy!