Rust in 20 minutes
This tour is for reading, not writing. Each section shows Java on the left (or on top, on a phone) and the Rust that you will see. The notes under each pair give only the Rust difference. Each topic has its own concept page; follow the links when you need more.
Variables: let, let mut, and types after the name
Section titled “Variables: let, let mut, and types after the name”Java
final int count = 3;var total = 0;total += count;var doubled = total * 2;Rust
let count: i32 = 3; // immutable, type written after the namelet mut total = 0; // `mut` makes it changeable; type is inferredtotal += count;let total = total * 2; // a new `total` that hides the old oneletis likefinal var: the binding cannot change. Addmutto allow changes. Immutable is the default, the opposite of Java.- The type comes after the name:
count: i32. Usually the compiler infers it, as withvar. - A second
let totalshadows the first one: it is a new variable with the same name. This is normal Rust, not a smell. - Integer types say their size:
i32,u64(unsigned),usize(the size of a pointer, used for lengths and indexes).
Functions, and the last expression as the return value
Section titled “Functions, and the last expression as the return value”Java
static long netPrice(long grossCents, long discountPercent) { long discount = grossCents * discountPercent / 100; return grossCents - discount;}
static long shippingCents(long totalCents) { return totalCents >= 5_000 ? 0 : 495;}Rust
fn net_price(gross_cents: u64, discount_percent: u64) -> u64 { let discount = gross_cents * discount_percent / 100; gross_cents - discount // no `;`: this value is returned}
fn shipping_cents(total_cents: u64) -> u64 { if total_cents >= 5_000 { 0 } else { 495 } // `if` returns a value}fn name(param: Type) -> ReturnType. No->means the function returns(), likevoid.- The last line without a
;is the return value.returnexists, but idiomatic code uses it only to leave early. ifis an expression, like the Java ternary operator. So arematchand blocks{ ... }.
Structs and impl: data and behavior are separate
Section titled “Structs and impl: data and behavior are separate”Java
public static final class Order { public final long id; public final String customer; private final List<Long> lines = new ArrayList<>();
public Order(long id, String customer) { this.id = id; this.customer = customer; }
public void addLine(long priceCents) { lines.add(priceCents); }
public long totalCents() { return lines.stream().mapToLong(Long::longValue).sum(); }
// equals, hashCode, toString: by hand, by the IDE, or by Lombok @Override public boolean equals(Object o) { return o instanceof Order other && id == other.id; } @Override public int hashCode() { return Objects.hash(id); } @Override public String toString() { return "Order[" + id + "]"; }}Rust
#[derive(Debug, Clone, PartialEq)]pub struct Order { pub id: u64, pub customer: String, lines: Vec<u64>, // private: visible only in this module}
impl Order { pub fn new(id: u64, customer: &str) -> Self { Self { id, customer: customer.to_string(), lines: Vec::new(), } }
pub fn add_line(&mut self, price_cents: u64) { self.lines.push(price_cents); }
pub fn total_cents(&self) -> u64 { self.lines.iter().sum() }}- A
structholds data. Methods go in a separateimplblock. There are no classes and no inheritance. #[derive(Debug, Clone, PartialEq)]generates the code that you write by hand (or with Lombok) in Java: a debugtoString(Debug), a copy method (Clone), andequals(PartialEq).- Items are private to their module by default.
pubmakes them public. Thelinesfield has nopub, so code outside this module cannot see it. newis a convention, not a keyword.Selfmeans “the type of thisimpl”.- The first parameter says how a method uses the object:
&selfreads it,&mut selfchanges it,selfconsumes it. See Structs and impl.
Enums and match: sealed interfaces with records
Section titled “Enums and match: sealed interfaces with records”Java
sealed interface Payment permits Card, Invoice, Cash {}record Card(String last4) implements Payment {}record Invoice(int dueDays) implements Payment {}record Cash() implements Payment {}
static String describe(Payment payment) { return switch (payment) { case Card c -> "card ending " + c.last4(); case Invoice i -> "invoice, due in " + i.dueDays() + " days"; case Cash c -> "cash"; };}Rust
pub enum Payment { Card { last4: String }, Invoice { due_days: u32 }, Cash,}
pub fn describe(payment: &Payment) -> String { match payment { Payment::Card { last4 } => format!("card ending {last4}"), Payment::Invoice { due_days } => format!("invoice, due in {due_days} days"), Payment::Cash => "cash".to_string(), }}- A Rust
enumis like a Java sealed interface with one record for each case. Each variant can carry different data. matchis like aswitchexpression with patterns. It must handle every variant, or the code does not compile.Payment::Card { last4 }takes the field out of the variant (destructuring).format!builds aString;{last4}inserts a variable. See Enums and pattern matching.
Option: no null
Section titled “Option: no null”Java
static int discountFor(Map<String, Integer> codes, String code) { Integer percent = codes.get(code); // null if missing return percent != null ? percent : 0;}Rust
pub fn discount_for(codes: &HashMap<String, u8>, code: &str) -> u8 { match codes.get(code) { Some(percent) => *percent, None => 0, }}HashMap::getreturnsOption<&u8>:Some(value)orNone. There is nonull, so the caller cannot forget the missing case.*percentreads the value behind the reference (&u8tou8).- Idiomatic code often shortens this
matchtocodes.get(code).copied().unwrap_or(0). See Option, not null.
Result and ?: errors as return values
Section titled “Result and ?: errors as return values”Java
static int parseQuantity(String text) throws NumberFormatException { int quantity = Integer.parseInt(text.trim()); // throws on bad input return quantity;}Rust
pub fn parse_quantity(text: &str) -> Result<u32, ParseIntError> { let quantity = text.trim().parse::<u32>()?; // on error: return it now Ok(quantity)}Result<u32, ParseIntError>isOk(u32)orErr(ParseIntError). The error type is part of the signature, like athrowsclause.?means: “if this is an error, return it to my caller now; if not, give me the value”. It is the most important symbol in Rust code.parse::<u32>()gives the target type explicitly. The::<>is called the “turbofish”.- There is no
try/catch. Code that must handle an error usesmatchor a combinator. See Result, not exceptions.
References: & to read, &mut to change
Section titled “References: & to read, &mut to change”Java
static long totalCents(long[] prices) { return LongStream.of(prices).sum();}
static void applyDiscount(long[] prices) { for (int i = 0; i < prices.length; i++) { prices[i] = prices[i] * 9 / 10; }}Rust
fn total_cents(prices: &[u64]) -> u64 { // `&`: borrow to read prices.iter().sum()}
fn apply_discount(prices: &mut [u64]) { // `&mut`: borrow to change for price in prices.iter_mut() { *price = *price * 9 / 10; // `*` reads or writes through the reference }}The call site:
let mut prices = vec![1_000, 2_500];apply_discount(&mut prices);let total = total_cents(&prices);&[u64]is a shared reference to a slice (a view of a list). The function can read it.&mut [u64]is a mutable reference. The function can change it. While it exists, no other reference to the data can exist.- The call site also says what happens:
&priceslends to read,&mut priceslends to change, and plainpriceswould move the list into the function. After a move, the caller cannot usepricesany more. - This is the single biggest difference from Java. Read Ownership and moves and Borrowing early.
Collections and for loops
Section titled “Collections and for loops”Java
var skus = List.of("A-1", "B-2", "A-1");Map<String, Integer> counts = new HashMap<>();for (var sku : skus) { counts.merge(sku, 1, Integer::sum);}Rust
let skus = ["A-1", "B-2", "A-1"];let mut counts: HashMap<String, u32> = HashMap::new();for sku in &skus { *counts.entry(sku.to_string()).or_insert(0) += 1;}Vec<T>isArrayList<T>.HashMap<K, V>isHashMap<K, V>. A fixed list such as["A-1", "B-2"]is an array.for sku in &skusborrows each item.for sku in skuswould consume the list.entry(...).or_insert(0)is the Rust form ofmergeorcomputeIfAbsent."A-1"has type&str(a borrowed string)..to_string()makes an ownedStringfor the map key. See Strings.
Closures and iterator chains
Section titled “Closures and iterator chains”Java
static List<String> bigSpenders(List<SimpleOrder> orders, long minCents) { return orders.stream() .filter(o -> o.totalCents() >= minCents) .map(SimpleOrder::customer) .toList();}Rust
pub fn big_spenders(orders: &[Order], min_cents: u64) -> Vec<&str> { orders .iter() .filter(|o| o.total_cents >= min_cents) // `|o|` starts a closure .map(|o| o.customer.as_str()) .collect()}|o| o.total_cents >= min_centsis a closure: Javao -> o.totalCents() >= minCents..iter()is like.stream(). Adapters are lazy..collect()is the terminal operation, and the return type tells it what to build.Vec<&str>returns borrowed names, so nothing is copied. The compiler makes sure that the result does not outliveorders.- See Iterators vs streams and Closures.
Macros: the !
Section titled “Macros: the !”Java
var ids = new ArrayList<>(List.of(101, 102));ids.add(103);System.out.printf("%d orders for %s%n", ids.size(), customer);var summary = String.format("%s: %d cents", customer, totalCents);Rust
let mut ids = vec![101, 102]; // `vec!` builds a Vecids.push(103);println!("{} orders for {customer}", ids.len()); // print to stdoutlet summary = format!("{customer}: {total_cents} cents"); // build a String- A name followed by
!is a macro: it generates code at compile time. You will seeprintln!,format!,vec!,assert_eq!, andwrite!everywhere. {}in a format string inserts a value.{customer}inserts a variable by name.{:?}uses theDebugformat (for logs and tests).- Attributes such as
#[derive(...)]and#[tokio::main]are also macros. See Macros.
Modules: use and ::
Section titled “Modules: use and ::”Java
// import java.util.TreeMap; (at the top of the file)static String report(TreeMap<String, Long> totals) { var out = new StringBuilder(); totals.forEach((customer, cents) -> out.append(customer).append(": ").append(cents).append('\n')); return out.toString();}Rust
use std::collections::BTreeMap; // like `import java.util.TreeMap;`use std::fmt::Write as _; // bring a trait into scope, unnamed
pub fn report(totals: &BTreeMap<String, u64>) -> String { let mut out = String::new(); for (customer, cents) in totals { let _ = writeln!(out, "{customer}: {cents}"); // writing to a String cannot fail } out}use std::collections::BTreeMap;is animport.::separates the parts of a path, like.in a Java package.stdis the standard library. Other first names in a path are crates (dependencies) or modules of the same crate (crate::,super::).- Some methods come from a trait and work only when the trait is in scope.
use std::fmt::Write as _;bringswriteln!support forStringinto scope without a name. - See Modules, crates, and Cargo.
Generics, impl Trait, and where
Section titled “Generics, impl Trait, and where”Java
static <T> String label(T item) { return "[" + item + "]";}
static <T> String joinAll(Iterable<T> items) { var parts = new ArrayList<String>(); items.forEach(i -> parts.add(String.valueOf(i))); return String.join(", ", parts);}
static IntStream orderIds() { return IntStream.rangeClosed(1, 3).map(n -> n * 100);}Rust
pub fn label<T: Display>(item: T) -> String { format!("[{item}]")}
pub fn join_all<I>(items: I) -> Stringwhere I: IntoIterator, I::Item: Display,{ items .into_iter() .map(|i| i.to_string()) .collect::<Vec<_>>() .join(", ")}
pub fn order_ids() -> impl Iterator<Item = u64> { (1..=3).map(|n| n * 100)}<T: Display>is a bound, like<T extends Comparable<T>>.Displayis the trait for user-facing text (JavatoString).wheremoves the bounds after the signature. It means the same thing; it is easier to read when there are many bounds.-> impl Iterator<Item = u64>means “some concrete iterator type that I do not name”. Like returning anIntStream, but without an interface call at run time.- Rust generics are not erased: the compiler makes a copy of the function for each type. See Generics.
Lifetimes: 'a
Section titled “Lifetimes: 'a”Java has no equivalent. You will see this, and you can mostly read past it at first:
pub fn longer_name<'a>(first: &'a str, second: &'a str) -> &'a str { if second.len() > first.len() { second } else { first }}'ais a lifetime: a name for “how long this reference is valid”. Here it says: “the result borrows fromfirstorsecond, so it is valid only while both are”.- Lifetime annotations do not change how long anything lives. They are labels that let the compiler check borrows.
- Most functions need no annotations. When AI-generated code has many of them, look closer. See Lifetimes.
Keep the symbol decoder open while you read real code. Then try your first review.