Rust for Python Programmers: A Comprehensive Guide
Introduction
If you’re a Python programmer considering learning Rust, you’re embarking on a journey that will fundamentally change how you think about systems programming. While Python’s philosophy prioritizes readability and developer ergonomics, Rust emphasizes safety, performance, and explicit control. This guide bridges the gap by demonstrating Rust concepts through the lens of Python, making it accessible regardless of your experience level.
Rust is often positioned as a replacement for C and C++ for systems-level programming, but it’s increasingly being used for high-performance applications, WebAssembly, embedded systems, and even backend web services. For Python developers, learning Rust can be transformative for building fast, reliable components that complement Python codebases.
Why Learn Rust as a Python Programmer?
Before diving into syntax and concepts, let’s understand the motivation. Python is dynamically typed, interpreted, and prioritizes developer velocity. Rust is statically typed, compiled, and prioritizes memory safety and performance. Learning Rust teaches you about memory management, concurrency patterns, and error handling in ways that will make you a better programmer overall.
Key benefits include:
- Performance: Rust code runs as fast as C++, orders of magnitude faster than Python
- Safety: The borrow checker prevents entire classes of bugs at compile time
- Concurrency: Fearless concurrency through ownership semantics
- Interoperability: Easy to bind Rust from Python for performance-critical sections
Setting Up
Before we begin, install Rust from rustup.rs. This installs the Rust toolchain and the Cargo package manager.
Create a new project with:
cargo new my_rust_project
cd my_rust_project
This creates a basic project structure. We’ll use cargo run
to execute Rust programs and cargo test
for tests.
Part 1: Basic Syntax and Data Types
Hello World
The most traditional first program:
Python:
print("Hello, World!")
Rust:
fn main() {
println!("Hello, World!");
}
The Rust version shows several conventions: functions are declared with fn
, main()
is the entry point, and println!
is a macro (indicated by !
). Rust doesn’t have implicit returns for scripts like Python does.
Variables and Mutability
This is where Rust’s design philosophy first becomes apparent. In Python, variables are mutable by default. In Rust, they’re immutable by default.
Python:
x = 5
x = 10 # This is normal and expected
print(x)
Rust:
fn main() {
let x = 5;
x = 10; // ERROR: cannot assign twice to immutable variable
println!("{}", x);
}
To make variables mutable in Rust, you must explicitly declare them:
Python (mutable):
counter = 0
counter += 1
counter += 1
print(counter) # Output: 2
Rust (mutable):
fn main() {
let mut counter = 0;
counter += 1;
counter += 1;
println!("{}", counter); // Output: 2
}
This immutability-by-default approach prevents accidental mutations and makes code easier to reason about. It’s one of Rust’s most distinctive features.
Basic Data Types
Python:
# Python's dynamic typing
integer = 42
floating_point = 3.14
string = "Hello"
boolean = True
nothing = None
print(type(integer)) # <class 'int'>
print(type(floating_point)) # <class 'float'>
Rust:
fn main() {
// Type annotations are often required in Rust
let integer: i32 = 42; // 32-bit signed integer
let floating_point: f64 = 3.14; // 64-bit float
let string: &str = "Hello"; // String slice
let boolean: bool = true; // Boolean
println!("integer: {}", integer);
println!("float: {}", floating_point);
println!("string: {}", string);
println!("bool: {}", boolean);
}
Rust has specific integer types: i8
, i16
, i32
, i64
, i128
for signed integers, and u8
, u16
, u32
, u64
, u128
for unsigned. The i
and u
prefixes denote signed and unsigned. This explicit specification prevents overflow bugs common in C.
Collections: Arrays and Vectors
Python:
# Python lists are dynamic
list1 = [1, 2, 3, 4, 5]
list1.append(6)
print(list1) # [1, 2, 3, 4, 5, 6]
# Tuples are immutable
tuple1 = (1, 2, 3)
# tuple1[0] = 10 # TypeError: 'tuple' object does not support item assignment
Rust:
fn main() {
// Arrays have fixed size, known at compile time
let array: [i32; 5] = [1, 2, 3, 4, 5];
println!("array: {:?}", array);
// Vectors are dynamic, like Python lists
let mut vec = vec![1, 2, 3, 4, 5]; // vec! is a macro that creates vectors
vec.push(6);
println!("vector: {:?}", vec);
// Tuples with different types
let tuple: (i32, f64, &str) = (42, 3.14, "hello");
println!("tuple: {:?}", tuple);
println!("first element: {}", tuple.0); // Access by index
}
The key difference: Rust arrays have fixed size at compile time, while vectors are dynamic. The type annotation [i32; 5]
means “array of 5 i32s”. Vectors use Vec<T>
type internally.
Strings: A Special Case
Strings deserve special attention because Rust’s string handling differs significantly from Python.
Python:
# Python strings are simple and unified
s1 = "Hello"
s2 = String("Hello") # Also string (Python 3 uses str)
s3 = s1 + " " + "World" # Concatenation is straightforward
print(len(s1)) # 5
print(s1[0]) # 'H'
Rust:
fn main() {
// &str: string slice (borrowed reference to string data)
let s1: &str = "Hello";
// String: owned, heap-allocated string
let mut s2: String = String::from("Hello");
s2.push_str(" World");
// Concatenation
let s3 = format!("{} {}", s1, "World");
println!("length: {}", s1.len());
println!("first char: {}", s1.chars().next().unwrap());
// Iteration over characters
for c in s1.chars() {
println!("{}", c);
}
}
Rust has two main string types: &str
(string slice, usually references string literals) and String
(owned, mutable, heap-allocated). This distinction exists because Rust must track ownership of memory. We’ll discuss this more in the ownership section.
Part 2: Control Flow
If-Else Statements
Python:
age = 20
if age < 13:
print("Child")
elif age < 18:
print("Teen")
else:
print("Adult")
# If as expression (Python 3.10+)
category = "Child" if age < 13 else "Adult"
Rust:
fn main() {
let age = 20;
if age < 13 {
println!("Child");
} else if age < 18 {
println!("Teen");
} else {
println!("Adult");
}
// If-else as expression (returns value)
let category = if age < 13 { "Child" } else { "Adult" };
println!("{}", category);
}
Rust if-else blocks are expressions that return values, similar to Python’s ternary operator but more flexible.
Loops
Python:
# While loop
count = 0
while count < 5:
print(count)
count += 1
# For loop with range
for i in range(5):
print(i)
# For loop with list
numbers = [1, 2, 3, 4, 5]
for num in numbers:
print(num)
Rust:
fn main() {
// While loop
let mut count = 0;
while count < 5 {
println!("{}", count);
count += 1;
}
// For loop with range
for i in 0..5 { // 0..5 is a range (exclusive end)
println!("{}", i);
}
// For loop with range (inclusive)
for i in 0..=5 { // 0..=5 is inclusive
println!("{}", i);
}
// For loop with vector
let numbers = vec![1, 2, 3, 4, 5];
for num in numbers {
println!("{}", num);
}
// Loop (infinite, must break explicitly)
let mut x = 0;
loop {
println!("{}", x);
x += 1;
if x >= 5 {
break;
}
}
}
Rust’s loop
is an infinite loop that must be explicitly broken. The ..
operator creates ranges, and 0..5
is exclusive (doesn’t include 5), while 0..=5
is inclusive.
Match: Rust’s Pattern Matching
Python uses switch-case (Python 3.10+) or if-elif chains. Rust uses match
, which is more powerful:
Python:
day = 3
if day == 1:
print("Monday")
elif day == 2:
print("Tuesday")
elif day == 3:
print("Wednesday")
else:
print("Other")
Rust:
fn main() {
let day = 3;
match day {
1 => println!("Monday"),
2 => println!("Tuesday"),
3 => println!("Wednesday"),
_ => println!("Other"), // Catch-all pattern (underscore)
}
// Match returns values
let day_name = match day {
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
_ => "Other",
};
println!("{}", day_name);
}
The underscore _
is Rust’s way of saying “match anything else”. Match expressions can also return values.
Part 3: Functions and Error Handling
Functions and Return Values
Python:
def add(a, b):
return a + b
result = add(5, 3)
print(result) # 8
# Function without explicit return
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Function that may not return
def maybe_divide(a, b):
if b == 0:
return None
return a / b
result = maybe_divide(10, 2)
Rust:
// Function with return type annotation
fn add(a: i32, b: i32) -> i32 {
a + b // Notice: no semicolon! This returns the value
}
// Function with explicit return
fn add_explicit(a: i32, b: i32) -> i32 {
return a + b; // Semicolon makes it a statement, not expression
}
// Function with no return value
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let result = add(5, 3);
println!("{}", result); // 8
greet("Alice");
}
Key observation: Rust distinguishes between expressions (no semicolon) and statements (with semicolon). a + b
without a semicolon is an expression that returns the value. return a + b;
is a statement.
Error Handling: From None to Result
Python uses exceptions and None:
Python:
def divide(a, b):
if b == 0:
return None # Or raise an exception
return a / b
result = divide(10, 2)
if result is None:
print("Division by zero")
else:
print(f"Result: {result}")
Rust uses the Result
type, which is an enum that represents either success (Ok
) or failure (Err
):
Rust:
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("Division by zero"))
} else {
Ok(a / b)
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(err) => println!("Error: {}", err),
}
}
This approach is more explicit and composable. You can chain operations using combinators:
Python:
def safe_divide(a, b):
if b == 0:
return None
return a / b
result = safe_divide(10, 2)
if result is not None:
result = safe_divide(result, 2)
if result is not None:
print(result)
Rust:
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("Division by zero"))
} else {
Ok(a / b)
}
}
fn main() {
let result = divide(10.0, 2.0)
.and_then(|x| divide(x, 2.0))
.and_then(|x| divide(x, 2.0));
match result {
Ok(final_result) => println!("{}", final_result),
Err(err) => println!("Error: {}", err),
}
}
The ?
operator (the “try” operator) provides syntactic sugar for error propagation:
Rust with ?
:
fn calculate(a: f64, b: f64) -> Result<f64, String> {
let step1 = divide(a, b)?; // If Err, return immediately
let step2 = divide(step1, 2.0)?;
Ok(step2)
}
fn main() {
match calculate(10.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(err) => println!("Error: {}", err),
}
}
This is cleaner than Python’s exception handling in many cases because errors are tracked in the type system.
Part 4: Ownership and Borrowing (The Big One)
This is Rust’s most distinctive feature and likely the most challenging concept for Python programmers. Python has automatic garbage collection. Rust uses ownership.
The Three Rules of Ownership
- Each value has one owner
- You can borrow the value (create references)
- When the owner is dropped, the value is freed
Moving Values
Python (reference semantics):
def take_list(lst):
print(lst)
my_list = [1, 2, 3]
take_list(my_list)
print(my_list) # Works fine - my_list still exists
Rust (ownership transfer):
fn take_vector(vec: Vec<i32>) {
println!("{:?}", vec);
}
fn main() {
let my_vec = vec![1, 2, 3];
take_vector(my_vec);
// println!("{:?}", my_vec); // ERROR: my_vec no longer owns the vector
}
When you pass my_vec
to take_vector
, ownership transfers. The original binding can’t access the data anymore. This prevents multiple parts of code from modifying the same data simultaneously.
To share without moving, you borrow:
Rust (borrowing):
fn print_vector(vec: &Vec<i32>) { // & means borrow (immutable reference)
println!("{:?}", vec);
}
fn main() {
let my_vec = vec![1, 2, 3];
print_vector(&my_vec); // Borrow with &
println!("{:?}", my_vec); // Still works!
}
Mutable Borrowing
Python:
def modify_list(lst):
lst.append(4)
my_list = [1, 2, 3]
modify_list(my_list)
print(my_list) # [1, 2, 3, 4]
Rust:
fn modify_vector(vec: &mut Vec<i32>) { // &mut for mutable borrow
vec.push(4);
}
fn main() {
let mut my_vec = vec![1, 2, 3];
modify_vector(&mut my_vec); // Borrow mutably with &mut
println!("{:?}", my_vec); // [1, 2, 3, 4]
}
Critical rule: You can have either one mutable reference OR multiple immutable references at a time (but not both). This prevents data races:
Rust (this won’t compile):
fn main() {
let mut vec = vec![1, 2, 3];
let ref1 = &vec; // Immutable borrow
let ref2 = &vec; // Another immutable borrow (OK)
vec.push(4); // ERROR: Can't mutate while borrowed
println!("{:?}", ref1);
}
Lifetimes
When references exist, Rust needs to ensure they don’t outlive the data they reference. This is managed through lifetimes:
Python (no lifetime concerns):
def get_first(lst):
return lst[0]
my_list = [1, 2, 3]
first = get_first(my_list)
print(first)
Rust (with lifetimes):
// The 'a is a lifetime parameter
fn get_first<'a>(vec: &'a Vec<i32>) -> &'a i32 {
&vec[0]
}
fn main() {
let my_vec = vec![1, 2, 3];
let first = get_first(&my_vec);
println!("{}", first);
}
The 'a
lifetime annotation tells the compiler that the returned reference is valid as long as the input reference is valid. Most lifetimes are inferred automatically, so you rarely need to write them explicitly.
A common error:
Rust (won’t compile):
fn get_first(vec: &Vec<i32>) -> &i32 {
&vec[0]
}
fn main() {
let first;
{
let my_vec = vec![1, 2, 3];
first = get_first(&my_vec);
} // my_vec dropped here
println!("{}", first); // ERROR: first refers to dropped data
}
Part 5: Structs and Traits
Structs: Grouping Data
Python:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self):
print(f"Hello, my name is {self.name}")
person = Person("Alice", 30)
person.greet()
Rust:
struct Person {
name: String,
age: u32,
}
impl Person {
fn greet(&self) { // &self means borrow self
println!("Hello, my name is {}", self.name);
}
fn have_birthday(&mut self) { // &mut self for mutation
self.age += 1;
}
}
fn main() {
let mut person = Person {
name: String::from("Alice"),
age: 30,
};
person.greet();
person.have_birthday();
println!("{}", person.age); // 31
}
Structs in Rust are just data; methods are defined separately in impl
blocks. Notice &self
vs &mut self
vs owned self
control how the method interacts with the instance.
Traits: Defining Behavior
Python:
class Animal:
def speak(self):
raise NotImplementedError
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
def make_sound(animal):
print(animal.speak())
make_sound(Dog())
make_sound(Cat())
Rust:
trait Animal {
fn speak(&self) -> &str;
}
struct Dog;
struct Cat;
impl Animal for Dog {
fn speak(&self) -> &str {
"Woof!"
}
}
impl Animal for Cat {
fn speak(&self) -> &str {
"Meow!"
}
}
fn make_sound(animal: &dyn Animal) {
println!("{}", animal.speak());
}
fn main() {
let dog = Dog;
let cat = Cat;
make_sound(&dog);
make_sound(&cat);
}
dyn Animal
is a trait object, similar to Python’s duck typing but type-checked at compile time.
You can also use generics for compile-time polymorphism (monomorphization):
Rust (with generics):
fn make_sound<T: Animal>(animal: &T) {
println!("{}", animal.speak());
}
fn main() {
let dog = Dog;
let cat = Cat;
make_sound(&dog);
make_sound(&cat);
}
Part 6: Pattern Matching and Enums
Enums and Pattern Matching
Python:
def process_value(value):
if value is None:
print("No value")
elif isinstance(value, int):
print(f"Integer: {value}")
elif isinstance(value, str):
print(f"String: {value}")
else:
print("Unknown")
process_value(42)
process_value("hello")
process_value(None)
Rust:
enum Value {
None,
Integer(i32),
String(String),
}
fn process_value(value: Value) {
match value {
Value::None => println!("No value"),
Value::Integer(n) => println!("Integer: {}", n),
Value::String(s) => println!("String: {}", s),
}
}
fn main() {
process_value(Value::Integer(42));
process_value(Value::String(String::from("hello")));
process_value(Value::None);
}
Rust’s match
is exhaustive; you must handle all cases. This prevents bugs where you forget to handle a variant.
Part 7: Collections and Iteration
HashMap (Dictionary)
Python:
person = {
"name": "Alice",
"age": 30,
"city": "NYC"
}
print(person["name"])
person["job"] = "Engineer"
for key, value in person.items():
print(f"{key}: {value}")
Rust:
use std::collections::HashMap;
fn main() {
let mut person = HashMap::new();
person.insert("name", "Alice");
person.insert("age", "30");
person.insert("city", "NYC");
println!("{:?}", person.get("name")); // Some("Alice")
person.insert("job", "Engineer");
for (key, value) in person.iter() {
println!("{}: {}", key, value);
}
}
Note that HashMap::get()
returns an Option<&V>
, which is Some(value)
or None
. You must handle both cases.
Iterator Methods
Python:
numbers = [1, 2, 3, 4, 5]
squared = [x**2 for x in numbers]
evens = [x for x in numbers if x % 2 == 0]
result = sum(numbers)
print(squared) # [1, 4, 9, 16, 25]
print(evens) # [2, 4]
Rust:
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let squared: Vec<_> = numbers.iter()
.map(|x| x * x)
.collect();
let evens: Vec<_> = numbers.iter()
.filter(|x| x % 2 == 0)
.collect();
let sum: i32 = numbers.iter().sum();
println!("{:?}", squared); // [1, 4, 9, 16, 25]
println!("{:?}", evens); // [2, 4]
println!("{}", sum); // 15
}
Rust iterators are lazy and composable, similar to Python’s generator expressions.
Part 8: Concurrency
Threads
Python:
import threading
def worker(n):
print(f"Worker {n} running")
threads = []
for i in range(3):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join()
print("Done")
Rust:
use std::thread;
fn main() {
let mut handles = vec![];
for i in 0..3 {
let handle = thread::spawn(move || {
println!("Worker {} running", i);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Done");
}
The move
keyword moves ownership of captured variables into the closure. The unwrap()
extracts the result or panics if there’s an error.
Channels for Thread Communication
Python:
from queue import Queue
import threading
def sender(queue):
for i in range(3):
queue.put(i)
def receiver(queue):
while True:
item = queue.get()
if item is None:
break
print(f"Received: {item}")
queue = Queue()
t1 = threading.Thread(target=sender, args=(queue,))
t2 = threading.Thread(target=receiver, args=(queue,))
t1.start()
t2.start()
t1.join()
queue.put(None) # Signal to stop
t2.join()
Rust:
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 0..3 {
tx.send(i).unwrap();
}
});
for received in rx {
println!("Received: {}", received);
}
}
Rust’s channels use the ownership system to ensure thread safety. The sender is moved into the thread, and only one part can own it at a time.
Part 9: Modules and Organization
File Structure
Python:
# myproject/
# ├── main.py
# ├── utils.py
# └── math_utils.py
# main.py
from utils import helper_function
from math_utils import add
result = add(5, 3)
Rust:
// myproject/
// ├── src/
// │ ├── main.rs
// │ ├── utils.rs
// │ └── math_utils.rs
// └── Cargo.toml
// src/main.rs
mod utils;
mod math_utils;
use math_utils::add;
fn main() {
let result = add(5, 3);
println!("{}", result);
}
// src/math_utils.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// src/utils.rs
pub fn helper_function() {
println!("Helper");
}
The pub
keyword makes items public. Without it, they’re private to the module.
Part 10: Testing
Writing Tests
Python:
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
if __name__ == "__main__":
test_add()
print("All tests passed")
Rust:
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);
assert_eq!(add(-1, 1), 0);
}
}
Run tests with cargo test
. The #[cfg(test)]
attribute ensures tests aren’t compiled in release builds.
Part 11: Integrating Rust with Python
For performance-critical sections, you can write Rust and call it from Python using PyO3:
Rust (in a Rust library project with PyO3 dependency):
use pyo3::prelude::*;
#[pyfunction]
fn fibonacci(n: u32) -> u32 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
#[pymodule]
fn my_rust_lib(py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(fibonacci, m)?)?;
Ok(())
}
Python:
import my_rust_lib
result = my_rust_lib.fibonacci(10)
print(result) # 55
This lets you keep Python’s ergonomics while gaining Rust’s performance for bottlenecks.
Common Pitfalls and Solutions
Pitfall 1: The Borrow Checker
Problem:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 moved to s2
println!("{}", s1); // ERROR
}
Solution:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // Explicitly clone
println!("{}", s1); // Works!
}
Or use references if you don’t need ownership:
fn main() {
let s1 = String::from("hello");
let s2 = &s1; // Borrow
println!("{}", s1); // Works!
}
Pitfall 2: Lifetimes in Function Signatures
Problem:
fn get_string() -> &String {
let s = String::from("hello");
&s // ERROR: s is dropped, reference outlives data
}
Solution: Return an owned value or take a reference as input:
// Solution 1: Return owned String
fn get_string() -> String {
String::from("hello")
}
// Solution 2: Accept reference parameter
fn process_string(s: &String) -> &str {
&s[0..5]
}
Pitfall 3: Moving in Closures
Problem:
fn main() {
let vec = vec![1, 2, 3];
let closure = || println!("{:?}", vec);
println!("{:?}", vec); // ERROR: vec moved into closure
}
Solution: Use immutable references or clone:
fn main() {
let vec = vec![1, 2, 3];
let closure = || println!("{:?}", &vec); // Borrow
println!("{:?}", vec); // Works!
}
Pitfall 4: Result and Option Handling
Problem:
fn main() {
let numbers = vec![1, 2, 3];
let first = numbers[10]; // PANIC at runtime!
}
Solution: Use safe indexing with Option:
fn main() {
let numbers = vec![1, 2, 3];
match numbers.get(10) {
Some(num) => println!("{}", num),
None => println!("Index out of bounds"),
}
// Or use if let
if let Some(num) = numbers.get(10) {
println!("{}", num);
}
}
Part 12: Advanced Pattern Matching
Destructuring
Rust’s pattern matching goes far beyond simple values. You can destructure complex data structures:
Python:
# Tuple unpacking
person = ("Alice", 30)
name, age = person
print(f"{name} is {age}")
# List unpacking
data = [1, 2, 3, 4, 5]
first, second, *rest = data
print(f"First: {first}, Rest: {rest}")
# Dictionary unpacking
config = {"host": "localhost", "port": 8080}
host = config.get("host")
port = config.get("port")
Rust:
fn main() {
// Tuple destructuring
let person = ("Alice", 30);
let (name, age) = person;
println!("{} is {}", name, age);
// Vector destructuring in match
let data = vec![1, 2, 3, 4, 5];
match data.as_slice() {
[first, second, rest @ ..] => {
println!("First: {}, Second: {}, Rest: {:?}", first, second, rest);
}
_ => {}
}
// Struct destructuring
struct Person {
name: String,
age: u32,
}
let person = Person {
name: String::from("Bob"),
age: 25,
};
let Person { name, age } = person;
println!("{} is {}", name, age);
}
Matching on References
Python:
def analyze(value):
if value is None:
print("Empty")
elif value == 0:
print("Zero")
else:
print(f"Value: {value}")
Rust:
fn analyze(value: Option<i32>) {
match value {
None => println!("Empty"),
Some(0) => println!("Zero"),
Some(n) => println!("Value: {}", n),
}
}
// Or with if let
fn analyze_short(value: Option<i32>) {
if let Some(n) = value {
if n == 0 {
println!("Zero");
} else {
println!("Value: {}", n);
}
} else {
println!("Empty");
}
}
Part 13: Generics Deep Dive
Generic Functions
Python:
def first(items):
"""Returns first item from any sequence"""
if len(items) > 0:
return items[0]
return None
print(first([1, 2, 3])) # 1
print(first(["a", "b"])) # "a"
print(first([])) # None
Rust:
fn first<T>(items: &[T]) -> Option<&T> {
if items.len() > 0 {
Some(&items[0])
} else {
None
}
}
fn main() {
println!("{:?}", first(&[1, 2, 3])); // Some(1)
println!("{:?}", first(&["a", "b"])); // Some("a")
println!("{:?}", first::<i32>(&[])); // None
}
The <T>
syntax declares a generic type parameter. Rust monomorphizes (creates separate compiled versions for each concrete type).
Generic Structs
Python:
class Box:
def __init__(self, value):
self.value = value
def get(self):
return self.value
box_int = Box(42)
box_str = Box("hello")
Rust:
struct Box<T> {
value: T,
}
impl<T> Box<T> {
fn new(value: T) -> Self {
Box { value }
}
fn get(&self) -> &T {
&self.value
}
}
fn main() {
let box_int = Box::new(42);
let box_str = Box::new("hello");
println!("{}", box_int.get());
println!("{}", box_str.get());
}
Trait Bounds
Constraining generic types with traits:
Python:
def print_all(items):
"""Print all items - assumes they have __str__"""
for item in items:
print(item)
Rust:
use std::fmt::Display;
fn print_all<T: Display>(items: &[T]) {
for item in items {
println!("{}", item);
}
}
fn main() {
print_all(&[1, 2, 3]);
print_all(&["a", "b", "c"]);
}
The T: Display
constraint means T must implement the Display
trait.
Multiple bounds:
use std::fmt::Display;
fn compare_and_print<T: Display + PartialOrd>(a: T, b: T) {
println!("{}", a);
println!("{}", b);
}
Part 14: Advanced Error Handling
Custom Error Types
Python:
class ValidationError(Exception):
pass
def validate_age(age):
if age < 0:
raise ValidationError("Age cannot be negative")
if age > 150:
raise ValidationError("Age seems unrealistic")
return age
try:
result = validate_age(200)
except ValidationError as e:
print(f"Error: {e}")
Rust:
use std::fmt;
#[derive(Debug)]
enum ValidationError {
NegativeAge,
UnrealisticAge,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ValidationError::NegativeAge => write!(f, "Age cannot be negative"),
ValidationError::UnrealisticAge => write!(f, "Age seems unrealistic"),
}
}
}
impl std::error::Error for ValidationError {}
fn validate_age(age: i32) -> Result<i32, ValidationError> {
if age < 0 {
Err(ValidationError::NegativeAge)
} else if age > 150 {
Err(ValidationError::UnrealisticAge)
} else {
Ok(age)
}
}
fn main() {
match validate_age(200) {
Ok(age) => println!("Valid age: {}", age),
Err(e) => println!("Error: {}", e),
}
}
Error Propagation
Python:
def read_and_parse(filename):
try:
with open(filename) as f:
content = f.read()
number = int(content)
return number
except FileNotFoundError:
print("File not found")
except ValueError:
print("Invalid number")
Rust:
use std::fs;
fn read_and_parse(filename: &str) -> Result<i32, Box<dyn std::error::Error>> {
let content = fs::read_to_string(filename)?;
let number = content.trim().parse::<i32>()?;
Ok(number)
}
fn main() {
match read_and_parse("number.txt") {
Ok(num) => println!("Number: {}", num),
Err(e) => println!("Error: {}", e),
}
}
The ?
operator automatically converts errors and propagates them up.
Part 15: Memory Management and Performance
Stack vs Heap
Python (abstracted away):
small = 42 # Small integer (may be cached)
large_list = [1] * 1000000 # Large list
Rust (explicit):
fn main() {
// Stack: fixed size, allocated at compile time
let small: i32 = 42;
let tuple: (i32, i32, i32) = (1, 2, 3);
// Heap: dynamic size, allocated at runtime
let vec = vec![1, 2, 3, 4, 5]; // Data on heap
let string = String::from("hello"); // Data on heap
// Stack size of vec is small (pointer + capacity + length)
// but the data it points to is on the heap
}
Rust’s ownership system determines when heap allocations are freed. When a value goes out of scope, its memory is freed. This is similar to RAII (Resource Acquisition Is Initialization) in C++.
Zero-Cost Abstractions
Rust is designed so high-level abstractions don’t add runtime cost:
Python:
# Easy but potentially slow
squared = [x**2 for x in range(100) if x % 2 == 0]
Rust (same performance as manual loops):
fn main() {
let squared: Vec<_> = (0..100)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.collect();
}
The Rust compiler optimizes these iterator chains to code as fast as hand-written loops.
Smart Pointers
For more complex memory scenarios:
Python (reference counting transparent):
import sys
my_list = [1, 2, 3]
another_ref = my_list
print(sys.getrefcount(my_list)) # Reference counting behind the scenes
Rust (explicit reference counting):
use std::rc::Rc;
fn main() {
let vec = Rc::new(vec![1, 2, 3]);
let another_ref = Rc::clone(&vec);
println!("Reference count: {}", Rc::strong_count(&vec));
// Both owners can access the data
println!("{:?}", vec);
println!("{:?}", another_ref);
}
Part 16: Macros
Macros are Rust’s metaprogramming feature, more powerful than Python’s decorators:
Python decorators:
def timing_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"Took {end - start} seconds")
return result
return wrapper
@timing_decorator
def slow_function():
time.sleep(1)
slow_function()
Rust macros (simplified example):
macro_rules! vec_sum {
($($x:expr),*) => {{
0 $(+ $x)*
}}
}
fn main() {
let sum = vec_sum![1, 2, 3, 4, 5];
println!("{}", sum); // 15
}
More commonly, you use existing macros like println!
, vec!
, assert!
, etc.
Procedural Macros (Attribute Macros)
Rust with derive macro:
#[derive(Debug, Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 1, y: 2 };
println!("{:?}", p);
let p2 = p.clone();
}
The #[derive(...)]
attribute automatically generates Debug
and Clone
implementations.
Part 17: Async and Await
Rust has first-class support for asynchronous programming:
Python (asyncio):
import asyncio
async def fetch_data(id):
await asyncio.sleep(1)
return f"Data for {id}"
async def main():
results = await asyncio.gather(
fetch_data(1),
fetch_data(2),
fetch_data(3),
)
print(results)
asyncio.run(main())
Rust (with tokio runtime):
use tokio::time::sleep;
use std::time::Duration;
async fn fetch_data(id: u32) -> String {
sleep(Duration::from_secs(1)).await;
format!("Data for {}", id)
}
#[tokio::main]
async fn main() {
let futures = vec![
fetch_data(1),
fetch_data(2),
fetch_data(3),
];
let results = futures::future::join_all(futures).await;
println!("{:?}", results);
}
Rust’s async is designed to be zero-cost and highly efficient, with colored async/await syntax that looks similar to Python.
Part 18: Working with Files and I/O
File Reading
Python:
# Read entire file
with open("file.txt") as f:
content = f.read()
# Read line by line
with open("file.txt") as f:
for line in f:
print(line.strip())
# Read as JSON
import json
with open("data.json") as f:
data = json.load(f)
Rust:
use std::fs;
use std::io::{self, BufRead};
fn main() -> io::Result<()> {
// Read entire file
let content = fs::read_to_string("file.txt")?;
println!("{}", content);
// Read line by line
let file = fs::File::open("file.txt")?;
let reader = io::BufReader::new(file);
for line in reader.lines() {
let line = line?;
println!("{}", line);
}
Ok(())
}
Using ?
operator with io::Result<T>
makes error handling clean.
Working with JSON
Python:
import json
data = {"name": "Alice", "age": 30}
json_string = json.dumps(data)
parsed = json.loads(json_string)
Rust:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u32,
}
fn main() {
let person = Person {
name: "Alice".to_string(),
age: 30,
};
let json_string = serde_json::to_string(&person).unwrap();
println!("{}", json_string);
let parsed: Person = serde_json::from_str(&json_string).unwrap();
println!("{:?}", parsed);
}
Rust’s serde library provides automatic serialization/deserialization with derive
macros.
Part 19: Building and Publishing Packages
Creating a Library
Python (library structure):
mylib/
├── mylib/
│ ├── __init__.py
│ └── core.py
└── setup.py
Rust (library structure):
mylib/
├── Cargo.toml
└── src/
└── lib.rs
Cargo.toml:
[package]
name = "mylib"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = “1.0”, features = [“derive”] }
Publishing to Registries
Python:
python setup.py sdist bdist_wheel
pip install twine
twine upload dist/*
Rust:
cargo publish
Rust publishes to crates.io by default. Just run cargo publish
and your package is available for others to use.
Part 20: Debugging and Troubleshooting
Using println! and dbg!
Python:
x = [1, 2, 3]
print(f"Debug: {x}")
Rust:
fn main() {
let x = vec![1, 2, 3];
// println! with Debug format
println!("Debug: {:?}", x);
// dbg! macro (prints and returns value)
let y = dbg!(x.len());
// Pretty print
println!("Pretty: {:#?}", x);
}
Understanding Compiler Errors
Rust’s compiler errors are notoriously helpful. For example:
Rust error example:
fn main() {
let s = String::from("hello");
let s2 = s;
println!("{}", s); // ERROR
}
Compiler output:
error[E0382]: borrow of moved value: `s`
--> src/main.rs:5:20
|
3 | let s2 = s;
| - value moved here
4 | println!("{}", s); // ERROR
| ^ value borrowed after move
|
= note: move occurs because `String` has type `String`,
which does not implement the `Copy` trait
help: consider cloning the value if the ownership is needed
|
3 | let s2 = s.clone();
| ^^^^^^^^
The compiler explains not just what’s wrong, but why and how to fix it.
Part 21: Performance Optimization
Benchmarking
Python:
import timeit
def slow_function():
return sum(range(1000000))
time = timeit.timeit(slow_function, number=100)
print(f"Time: {time}")
Rust:
#[cfg(test)]
mod benches {
use super::*;
#[test]
fn bench_sum() {
let sum: u64 = (0..1000000).sum();
println!("{}", sum);
}
}
For more advanced benchmarking, use the criterion
crate.
Release vs Debug
Python:
python myfile.py # Usually optimized
Rust:
cargo run # Debug (slow, good errors)
cargo run --release # Release (fast, optimized)
Debug builds are 10-100x slower but provide better error messages and debugging info. Always use --release
for performance testing.
Part 22: Real-World Example: Web Server
Let’s build a simple multithreaded HTTP server to tie everything together:
Rust Web Server:
use std::net::TcpListener;
use std::io::{Read, Write};
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080")
.expect("Failed to bind");
println!("Server running on http://127.0.0.1:8080");
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
thread::spawn(|| {
let mut buffer = [0; 512];
if let Ok(n) = stream.read(&mut buffer) {
let request = String::from_utf8_lossy(&buffer[..n]);
if request.contains("GET / HTTP") {
let response = "HTTP/1.1 200 OK\r\n\r\nHello, World!";
let _ = stream.write_all(response.as_bytes());
}
}
});
}
Err(e) => eprintln!("Connection error: {}", e),
}
}
}
This demonstrates:
- TCP networking
- Threading
- Error handling with Result
- String manipulation
- Ownership and lifetimes
- Pattern matching
Conclusion
Learning Rust as a Python programmer is challenging but rewarding. The concepts you’ve learned here—ownership, borrowing, traits, and error handling—represent a fundamentally different approach to systems programming compared to Python’s garbage-collected paradigm.
Key takeaways:
- Ownership is core: Understanding who owns what data is central to Rust programming
- The borrow checker is your friend: Despite initial frustration, it prevents entire classes of bugs
- Explicit is better than implicit: Rust forces you to be clear about intent, making code safer
- Type system is powerful: Rust’s types catch bugs at compile time that Python finds at runtime
- Performance without sacrifice: You get C-level performance without manual memory management
Start small: write command-line tools, integrate Rust into Python projects, build CLI applications. Gradually work up to more complex systems as you internalize Rust’s concepts.
The Rust community is welcoming and helpful. Don’t hesitate to ask questions in the Rust forums or on Stack Overflow. The official documentation at rust-lang.org is excellent.
Happy Rust programming!