Skip to content

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 name
let mut total = 0; // `mut` makes it changeable; type is inferred
total += count;
let total = total * 2; // a new `total` that hides the old one
  • let is like final var: the binding cannot change. Add mut to allow changes. Immutable is the default, the opposite of Java.
  • The type comes after the name: count: i32. Usually the compiler infers it, as with var.
  • A second let total shadows 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 (), like void.
  • The last line without a ; is the return value. return exists, but idiomatic code uses it only to leave early.
  • if is an expression, like the Java ternary operator. So are match and 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 struct holds data. Methods go in a separate impl block. 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 debug toString (Debug), a copy method (Clone), and equals (PartialEq).
  • Items are private to their module by default. pub makes them public. The lines field has no pub, so code outside this module cannot see it.
  • new is a convention, not a keyword. Self means “the type of this impl”.
  • The first parameter says how a method uses the object: &self reads it, &mut self changes it, self consumes 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 enum is like a Java sealed interface with one record for each case. Each variant can carry different data.
  • match is like a switch expression 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 a String; {last4} inserts a variable. See Enums and pattern matching.

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::get returns Option<&u8>: Some(value) or None. There is no null, so the caller cannot forget the missing case.
  • *percent reads the value behind the reference (&u8 to u8).
  • Idiomatic code often shortens this match to codes.get(code).copied().unwrap_or(0). See Option, not null.

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> is Ok(u32) or Err(ParseIntError). The error type is part of the signature, like a throws clause.
  • ? 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 uses match or a combinator. See Result, not exceptions.

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: &prices lends to read, &mut prices lends to change, and plain prices would move the list into the function. After a move, the caller cannot use prices any more.
  • This is the single biggest difference from Java. Read Ownership and moves and Borrowing early.

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> is ArrayList<T>. HashMap<K, V> is HashMap<K, V>. A fixed list such as ["A-1", "B-2"] is an array.
  • for sku in &skus borrows each item. for sku in skus would consume the list.
  • entry(...).or_insert(0) is the Rust form of merge or computeIfAbsent.
  • "A-1" has type &str (a borrowed string). .to_string() makes an owned String for the map key. See Strings.

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_cents is a closure: Java o -> 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 outlive orders.
  • See Iterators vs streams and Closures.

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 Vec
ids.push(103);
println!("{} orders for {customer}", ids.len()); // print to stdout
let summary = format!("{customer}: {total_cents} cents"); // build a String
  • A name followed by ! is a macro: it generates code at compile time. You will see println!, format!, vec!, assert_eq!, and write! everywhere.
  • {} in a format string inserts a value. {customer} inserts a variable by name. {:?} uses the Debug format (for logs and tests).
  • Attributes such as #[derive(...)] and #[tokio::main] are also macros. See Macros.

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 an import. :: separates the parts of a path, like . in a Java package.
  • std is 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 _; brings writeln! support for String into scope without a name.
  • See Modules, crates, and Cargo.

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) -> String
where
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>>. Display is the trait for user-facing text (Java toString).
  • where moves 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 an IntStream, 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.

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
}
}
  • 'a is a lifetime: a name for “how long this reference is valid”. Here it says: “the result borrows from first or second, 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.