
Learn Rust fundamentals through runnable examples, a practical CLI project, common beginner mistakes, coding exercises, and interview-ready answers.
Rust is often introduced with impressive phrases such as memory safety without garbage collection and fearless concurrency. Those ideas matter, but a beginner still needs practical answers: How do you create a project? Why does a value “move”? When should you borrow instead of clone? How do Option and Result replace null-heavy and exception-heavy code?
This guide answers those questions with runnable examples. It is written for developers who already know some JavaScript, PHP, Go, Java, or C++ and want a useful foundation for projects and job interviews.
All ordinary Rust examples in this article work with the current stable toolchain and Rust 2024 edition conventions. The official Rust Book and standard library documentation remain the best references when language details change.
Rust is a compiled, statically typed programming language designed for performance, reliability, and productive systems programming. It is used for command-line tools, network services, embedded software, WebAssembly, operating-system components, game engines, and performance-sensitive application code.
Rust’s defining feature is its ownership system. The compiler tracks who owns data, how long references remain valid, and whether shared access is safe. This catches many use-after-free, double-free, null-pointer, and data-race problems before the program runs.
Rust does not use a garbage collector. Memory is normally released automatically when its owner leaves scope. That gives developers low-level control without requiring most code to call free manually.
| Language | Execution model | Memory management | Strong point | Main contrast with Rust |
|---|---|---|---|---|
| Rust | Native compiled code | Ownership checked at compile time | Safety plus predictable performance | Stricter learning curve, especially ownership |
| C++ | Native compiled code | RAII, smart pointers, and manual control | Mature ecosystem and maximum control | Rust prevents more memory errors in safe code |
| JavaScript | JIT/interpreted in browser or runtime | Garbage collected | Web development and fast iteration | Rust is statically typed and compiled ahead of time |
| PHP | Interpreted/JIT on the server | Runtime-managed memory | Productive web backends | Rust offers lower-level control and stronger compile-time checks |
| Go | Native compiled code | Garbage collected | Simple services and concurrency | Rust provides more memory control; Go is usually quicker to learn |
Rust is not automatically the best language for every service. PHP or JavaScript may be better for a conventional content site; Go may suit a small cloud team that values simplicity; C++ may be required by an existing engine. Rust is compelling when correctness, latency, resource use, portability, or memory safety are central requirements.
The official installation method is rustup, which manages Rust toolchains and related tools.
On Linux or macOS:
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
On Windows, download rustup-init.exe from the official Rust installation page. Windows also needs the linker and native build tools described by the installer.
Verify the installation:
rustc --version
cargo --version
Create and run a project:
cargo new rust_basics
cd rust_basics
cargo run
Cargo is Rust’s build system and package manager. cargo new creates Cargo.toml and src/main.rs; cargo run builds and executes the program. Other essential commands are:
cargo check # Type-check without producing the final executable
cargo test # Run tests
cargo fmt # Format code
cargo clippy # Run helpful lints
cargo build --release
Variables are immutable by default. Add mut only when a value must change.
fn main() {
let language = "Rust";
let mut completed_lessons: u32 = 1;
completed_lessons += 1;
const MAX_ATTEMPTS: u8 = 3;
println!("{language}: {completed_lessons}/{MAX_ATTEMPTS}");
}
Here language cannot be reassigned, while completed_lessons can. Constants use const, require a type, and are evaluated at compile time.
Common scalar types include integers (i32, u64), floating-point numbers (f32, f64), bool, and char. Compound types include tuples and arrays.
fn main() {
let score: i32 = 87;
let ratio: f64 = 0.87;
let passed: bool = true;
let grade: char = 'A';
let candidate: (&str, i32) = ("Maya", score);
let attempts: [u8; 3] = [70, 82, 87];
println!("{} {} {} {:?} {:?}", ratio, passed, grade, candidate, attempts);
}
&str is a borrowed string slice, often used for fixed text. String owns growable UTF-8 text on the heap.
Rust functions declare parameter and return types. The final expression without a semicolon becomes the return value.
fn classify_score(score: u32) -> &'static str {
if score >= 80 {
"strong"
} else if score >= 60 {
"pass"
} else {
"retry"
}
}
fn main() {
for score in [55, 72, 91] {
println!("{score}: {}", classify_score(score));
}
let mut countdown = 3;
while countdown > 0 {
println!("{countdown}");
countdown -= 1;
}
let value = loop {
break 42;
};
println!("loop returned {value}");
}
if and loop are expressions, so they can produce values. Rust also supports for, while, and labeled loops.
The official ownership rules are simple to state:
Heap-owning values such as String move by default:
fn print_and_return(text: String) -> String {
println!("{text}");
text
}
fn main() {
let message = String::from("ownership matters");
let message = print_and_return(message);
println!("still owned here: {message}");
}
Passing message transfers ownership into the function. Returning it transfers ownership back. Simple fixed-size types such as integers implement Copy, so assigning or passing them copies the value instead.
You could clone a String, but cloning allocates and copies heap data. Prefer borrowing when a function only needs temporary access.
A reference lets code use a value without owning it.
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
fn add_period(text: &mut String) {
text.push('.');
}
fn main() {
let mut sentence = String::from("Rust catches memory mistakes");
println!("{} words", word_count(&sentence));
add_period(&mut sentence);
println!("{sentence}");
}
&sentence is a shared reference. &mut sentence is an exclusive mutable reference. At a given time, Rust allows either multiple shared references or one mutable reference—not both. This rule prevents unsafe concurrent mutation and invalidated references.
A lifetime describes how long a reference is valid. Most lifetimes are inferred. You write annotations when the compiler needs help relating input and output references.
fn longer<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
fn main() {
let first = String::from("ownership");
let second = String::from("borrow");
println!("{}", longer(&first, &second));
}
'a does not extend either string’s life. It tells the compiler that the returned reference cannot outlive the shorter valid lifetime of the two inputs. Lifetimes prevent dangling references.
Structs group related fields. Methods are defined in an impl block.
struct Candidate {
name: String,
score: u32,
}
impl Candidate {
fn passed(&self) -> bool {
self.score >= 60
}
}
fn main() {
let candidate = Candidate { name: "Lina".into(), score: 84 };
println!("{} passed: {}", candidate.name, candidate.passed());
}
Enums express a value that can be one of several variants, and each variant may carry different data.
enum InterviewStage {
Applied,
Technical { score: u32 },
Offer(String),
Rejected,
}
fn describe(stage: InterviewStage) -> String {
match stage {
InterviewStage::Applied => "application received".into(),
InterviewStage::Technical { score } if score >= 80 => format!("strong score: {score}"),
InterviewStage::Technical { score } => format!("score: {score}"),
InterviewStage::Offer(role) => format!("offer for {role}"),
InterviewStage::Rejected => "process ended".into(),
}
}
fn main() {
println!("{}", describe(InterviewStage::Technical { score: 88 }));
}
match must cover every possible variant. Guards such as if score >= 80 refine a pattern. if let is convenient when only one pattern matters.
Rust has no ordinary null value. Option<T> represents a value that may be present.
fn find_even(numbers: &[i32]) -> Option<i32> {
numbers.iter().copied().find(|n| n % 2 == 0)
}
fn main() {
match find_even(&[1, 3, 8, 9]) {
Some(value) => println!("found {value}"),
None => println!("no even value"),
}
}
Result<T, E> represents success or failure. The ? operator returns an error early and converts it when appropriate.
use std::{fs, io};
fn load_nonempty(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?;
if content.trim().is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "file is empty"));
}
Ok(content)
}
fn main() {
match load_nonempty("notes.txt") {
Ok(content) => println!("{content}"),
Err(error) => eprintln!("Could not load file: {error}"),
}
}
Use panic! for unrecoverable bugs or violated internal invariants, not routine user errors. Avoid unwrap() on untrusted input because it panics on None or Err.
Generics let code work with multiple types. Traits describe shared behavior and can constrain generics.
trait Summary {
fn summary(&self) -> String;
}
struct Article {
title: String,
}
impl Summary for Article {
fn summary(&self) -> String {
format!("Article: {}", self.title)
}
}
fn announce<T: Summary>(item: &T) {
println!("{}", item.summary());
}
fn largest<T: Ord + Copy>(items: &[T]) -> Option<T> {
items.iter().copied().max()
}
fn main() {
announce(&Article { title: "Learning Rust".into() });
println!("{:?}", largest(&[4, 9, 2]));
}
T: Summary is a trait bound. largest requires Ord for ordering and Copy to return a copied value. Rust generally uses monomorphization, generating type-specific compiled code for generic uses.
The standard library’s most common collections are Vec<T>, String, and HashMap<K, V>.
use std::collections::HashMap;
fn main() {
let mut scores = vec![70, 85, 85, 92];
scores.push(100);
let mut frequencies = HashMap::new();
for score in &scores {
*frequencies.entry(*score).or_insert(0) += 1;
}
println!("scores: {scores:?}");
println!("frequencies: {frequencies:?}");
}
Borrowing &scores lets the loop read the vector without consuming it. The entry API updates a value or inserts a default.
Modules organize code and control visibility.
mod scoring {
pub fn percentage(points: u32, total: u32) -> Option<f64> {
if total == 0 { None } else { Some(points as f64 / total as f64 * 100.0) }
}
}
fn main() {
println!("{:?}", scoring::percentage(42, 50));
}
Items are private by default. pub exposes an item to its parent module. Larger projects commonly place modules in separate files and import paths with use.
Dependencies are declared in Cargo.toml. cargo add serde can add a crate, while cargo update updates versions allowed by your manifest. Cargo.lock records exact resolved versions; applications normally commit it.
[dependencies]
serde = { version = "1", features = ["derive"] }
Useful practices include reviewing crate maintenance and licenses, keeping dependencies minimal, running cargo audit through the separately installed audit tool, and using cargo tree to understand transitive dependencies.
Rust’s type system prevents many data races. Shared mutable state commonly uses Arc<Mutex<T>>: Arc provides thread-safe shared ownership, and Mutex permits one accessor at a time.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0_u32));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut value = counter.lock().expect("mutex poisoned");
*value += 1;
}));
}
for handle in handles {
handle.join().expect("worker panicked");
}
println!("{}", *counter.lock().expect("mutex poisoned"));
}
The move closure takes ownership of each cloned Arc. Rust rejects references that might outlive their source and types that are unsafe to transfer across threads.
This small dependency-free project accepts add, list, and done commands and stores tasks in tasks.txt.
Create it:
cargo new task_tracker
cd task_tracker
Replace src/main.rs with:
use std::{env, fs, io, path::Path};
const FILE: &str = "tasks.txt";
#[derive(Debug)]
struct Task {
done: bool,
title: String,
}
impl Task {
fn parse(line: &str) -> Option<Self> {
let (status, title) = line.split_once('|')?;
Some(Self { done: status == "1", title: title.to_string() })
}
fn serialize(&self) -> String {
format!("{}|{}", if self.done { "1" } else { "0" }, self.title)
}
}
fn load_tasks() -> io::Result<Vec<Task>> {
if !Path::new(FILE).exists() {
return Ok(Vec::new());
}
Ok(fs::read_to_string(FILE)?.lines().filter_map(Task::parse).collect())
}
fn save_tasks(tasks: &[Task]) -> io::Result<()> {
let text = tasks.iter().map(Task::serialize).collect::<Vec<_>>().join("\n");
fs::write(FILE, text)
}
fn run() -> Result<(), String> {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = load_tasks().map_err(|e| e.to_string())?;
match args.as_slice() {
[command, title @ ..] if command == "add" && !title.is_empty() => {
tasks.push(Task { done: false, title: title.join(" ") });
save_tasks(&tasks).map_err(|e| e.to_string())?;
println!("Task added");
}
[command] if command == "list" => {
for (index, task) in tasks.iter().enumerate() {
let mark = if task.done { "x" } else { " " };
println!("{}: [{}] {}", index + 1, mark, task.title);
}
}
[command, number] if command == "done" => {
No approved comments are visible yet. New community replies may wait for moderation.