Variables & Mutability
Immutable by default
class Main {
public static void main(String[] args) {
int count = 5; // implicitly mutable — reassignment compiles fine
count = 10;
System.out.println(count);
}
} fn main() {
let count = 5;
// count = 10; // compile error: cannot assign twice to immutable variable
let mut count = 10; // must opt in with 'mut' to allow reassignment
println!("{count}");
} Java local variables are mutable unless declared
final. Rust reverses the default: every let binding is immutable unless explicitly marked mut. This is not a style preference — the compiler rejects a reassignment to a non-mut binding as an error, catching an entire class of accidental-mutation bugs before the program runs.final vs immutable let
class Main {
public static void main(String[] args) {
final int maxRetries = 3; // final is opt-in immutability
System.out.println(maxRetries);
}
} fn main() {
let max_retries = 3; // immutable is the default; no keyword needed
println!("{max_retries}");
} Java's
final is an opt-in annotation that most Java code omits, even though most local variables are never reassigned in practice. Rust flips this: immutability is free and automatic, and mutability is the deliberate, visible opt-in via mut. Reading Rust code, every non-mut binding is a guarantee, not a convention.Type inference
class Main {
public static void main(String[] args) {
var greeting = "hello"; // Java 10+ var — inferred as String
var count = 42; // inferred as int
System.out.println(greeting + " " + count);
}
} fn main() {
let greeting = "hello"; // inferred as &str
let count = 42; // inferred as i32
println!("{greeting} {count}");
} Both languages infer local variable types from the initializer. Java's
var is a syntactic convenience only for local variables. Rust's inference is more powerful — the compiler can look forward through the rest of the function to determine a type, so annotations are rarely needed even when the initializer alone is ambiguous.Variable shadowing
class Main {
public static void main(String[] args) {
String text = "42";
int number = Integer.parseInt(text); // must use a new variable name
System.out.println(number);
}
} fn main() {
let text = "42";
let text: i32 = text.parse().unwrap(); // re-declares 'text' with a new type
println!("{text}");
} Java has no shadowing within the same scope —
text would need a different name once its type changes. Rust's let introduces a brand-new binding each time, so re-using the identifier text with a different type is legal: the old binding is shadowed, not mutated. This idiom is common for a parse-then-use sequence without inventing throwaway names.Constants
class Main {
static final double GRAVITY = 9.8;
static final int MAX_PLAYERS = 8;
public static void main(String[] args) {
System.out.println(GRAVITY);
System.out.println(MAX_PLAYERS);
}
} const GRAVITY: f64 = 9.8;
const MAX_PLAYERS: u32 = 8;
fn main() {
println!("{GRAVITY}");
println!("{MAX_PLAYERS}");
} Rust
const requires an explicit type annotation and can be declared at module scope, outside any function — unlike Java, where a compile-time constant must live inside a class as a static final field. A Rust constant has no fixed memory address; the compiler inlines its value at every use site.Ownership
No garbage collector at all
class Main {
public static void main(String[] args) {
// Java: the JVM's garbage collector tracks every reachable object and
// reclaims memory automatically, at a time of its own choosing.
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
numbers = null; // the list becomes eligible for GC, but WHEN it is freed is not determined here
System.out.println("Reference cleared; the GC will reclaim it eventually.");
}
} fn main() {
let numbers = vec![1, 2, 3];
println!("{numbers:?}");
} // 'numbers' goes out of scope HERE — its memory is freed deterministically, at this exact line This is the single biggest conceptual shift moving from Java to Rust. The JVM runs a garbage collector that scans reachable objects and frees the rest on its own schedule — this is not a tunable knob, it is the entire memory model. Rust has no garbage collector and no runtime at all: the compiler determines, at compile time, exactly one owner for each value, and inserts code to free that value's memory the instant its owner goes out of scope. There are no GC pauses because there is no GC.
Move semantics — ownership transfer
class Main {
public static void main(String[] args) {
// Java: both variables reference the SAME heap object.
java.util.List<Integer> original = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
java.util.List<Integer> alias = original;
alias.add(4);
System.out.println(original.size()); // 4 — shared, mutation is visible through both names
}
} fn main() {
let original = vec![1, 2, 3];
let moved = original; // ownership of the Vec moves to 'moved'
// println!("{:?}", original); // compile error: value borrowed after move
println!("{moved:?}");
} In Java, assigning a reference-type variable to another variable copies the reference — both names point at the same heap object, and the garbage collector frees it once nothing points to it anymore. Rust has no such implicit aliasing for heap-owning types like
Vec or String: assignment moves ownership, and the original binding becomes invalid. The compiler enforces exactly one owner for a given value at any time, which is precisely what makes deterministic, GC-free cleanup possible.Deterministic cleanup on scope exit
class Main {
static class Resource implements AutoCloseable {
public void close() { System.out.println("Resource closed"); }
}
public static void main(String[] args) {
// Java needs try-with-resources to guarantee deterministic cleanup —
// ordinary objects rely on the GC's unpredictable schedule instead.
try (Resource resource = new Resource()) {
System.out.println("Using resource");
}
}
} struct Resource;
impl Drop for Resource {
fn drop(&mut self) {
println!("Resource closed"); // called automatically when the owner goes out of scope
}
}
fn main() {
let _resource = Resource;
println!("Using resource");
} // _resource's Drop::drop runs HERE, unconditionally, with no try-with-resources needed Java requires the explicit
try-with-resources pattern (and an AutoCloseable implementation) to guarantee deterministic cleanup; ordinary objects are left to the garbage collector's own schedule, and a forgotten close() call is a common resource leak. In Rust, every value's owner is known at compile time, so the compiler can call the Drop trait's drop method automatically the instant the owner's scope ends — no special syntax required, and it is impossible to forget.Moving a value into a function
class Main {
static void printAndKeep(java.util.List<Integer> items) {
System.out.println(items);
}
public static void main(String[] args) {
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
printAndKeep(numbers);
System.out.println(numbers.size()); // still usable — Java passed a reference, nothing was consumed
}
} fn print_and_consume(items: Vec<i32>) {
println!("{items:?}");
} // 'items' is dropped here — the function consumed ownership
fn main() {
let numbers = vec![1, 2, 3];
print_and_consume(numbers);
// println!("{}", numbers.len()); // compile error: 'numbers' was moved into the function
} Passing a Java object reference to a method never removes access from the caller — the caller can keep using it afterward, since the GC keeps the object alive as long as anyone references it. Passing a Rust value by value (not by reference) moves ownership into the function; when the function returns, that value is dropped, and the caller can no longer use its original binding. The next section shows how to lend a value instead of moving it.
Explicit cloning (deep copy)
class Main {
public static void main(String[] args) {
java.util.List<Integer> original = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
java.util.List<Integer> copy = new java.util.ArrayList<>(original); // explicit copy constructor
copy.add(4);
System.out.println(original.size()); // 3 — independent
}
} fn main() {
let original = vec![1, 2, 3];
let mut copy = original.clone(); // explicit deep copy — never implicit
copy.push(4);
println!("{}", original.len()); // 3 — independent
} When a Java program needs an independent copy rather than a shared reference, it calls a copy constructor or factory method explicitly — the same conscious choice Rust requires with
.clone(). The difference is what happens when you do not ask for a copy: Java silently shares the reference (both names alias the same object) while Rust silently moves ownership away from the original (the original name becomes unusable). Neither language ever copies a heap-allocated collection implicitly.Borrowing & References
Borrowing with & — shared references
class Main {
static void printItems(java.util.List<Integer> items) {
System.out.println(items); // Java passes a reference automatically; nothing is "borrowed" explicitly
}
public static void main(String[] args) {
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
printItems(numbers);
System.out.println(numbers.size()); // still accessible
}
} fn print_items(items: &Vec<i32>) { // '&' borrows — does not take ownership
println!("{items:?}");
}
fn main() {
let numbers = vec![1, 2, 3];
print_items(&numbers); // lend with &
println!("{}", numbers.len()); // still accessible — ownership never moved
} To avoid the move from the previous section, Rust lets you lend a reference with
&value instead of passing the value itself. A shared reference (&T) grants read-only access and does not transfer ownership, so the original binding remains valid after the call — conceptually close to how every Java object reference behaves, except Rust makes the borrow visible in both the function signature and the call site.Mutable borrowing with &mut
class Main {
static void appendItem(java.util.List<Integer> items, int value) {
items.add(value); // Java references are always mutable — no distinction from a read-only reference
}
public static void main(String[] args) {
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
appendItem(numbers, 4);
System.out.println(numbers.size()); // 4
}
} fn append_item(items: &mut Vec<i32>, value: i32) {
items.push(value); // requires a MUTABLE borrow — a plain & reference could not call push
}
fn main() {
let mut numbers = vec![1, 2, 3];
append_item(&mut numbers, 4); // explicit mutable borrow
println!("{}", numbers.len()); // 4
} Java has exactly one kind of object reference, and it is always capable of mutation — there is no compiler-enforced distinction between "I will only read this" and "I will modify this." Rust splits references into shared (
&T, read-only) and mutable (&mut T, read-write), and a function that needs to mutate a borrowed value must declare &mut in its signature and the caller must pass &mut explicitly.The borrow checker's core rule
class Main {
public static void main(String[] args) {
// Java places NO restriction on how many references to an object exist,
// nor on mixing readers and writers — this is exactly how data races happen
// when multiple threads hold references to the same mutable object.
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
java.util.List<Integer> readerOne = numbers;
java.util.List<Integer> readerTwo = numbers;
readerOne.add(4); // readerTwo is now silently affected too — Java allows this freely
System.out.println(readerTwo.size());
}
} fn main() {
let mut numbers = vec![1, 2, 3];
let reader_one = &numbers;
let reader_two = &numbers;
println!("{reader_one:?} {reader_two:?}"); // multiple shared borrows: fine
let writer = &mut numbers; // would be a compile error while reader_one/reader_two are still in use
writer.push(4);
println!("{writer:?}");
} Java places no restriction whatsoever on aliasing: any number of variables can reference the same mutable object simultaneously, and nothing stops one of them from mutating it while another reads it — this is precisely how data races occur across threads. Rust's borrow checker enforces, at compile time, that for any value you may have either any number of shared references (
&T) or exactly one mutable reference (&mut T), never both at once. This single rule is what makes Rust references memory-safe without a garbage collector and data-race-free without a runtime lock.Iterating without taking ownership
class Main {
public static void main(String[] args) {
java.util.List<String> fruits = java.util.List.of("apple", "banana", "cherry");
for (String fruit : fruits) {
System.out.println(fruit);
}
System.out.println(fruits.size()); // still usable after the loop
}
} fn main() {
let fruits = vec!["apple", "banana", "cherry"];
for fruit in &fruits { // '&' borrows each element instead of moving it
println!("{fruit}");
}
println!("{}", fruits.len()); // still usable after the loop
} Java's enhanced for loop iterates references without ever affecting what the original collection variable can do afterward. Rust's
for item in &collection mirrors this by borrowing each element; omitting the & (for item in collection) would instead move the collection into the loop and consume it, leaving fruits unusable afterward — a distinction with no Java equivalent, since Java references are never "consumed."No dangling references
class Main {
// Java: this pattern is impossible to write incorrectly — the GC keeps
// an object alive as long as any reference to it exists.
static java.util.List<Integer> makeList() {
java.util.List<Integer> local = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
return local; // safe: the GC will not reclaim it while the caller holds a reference
}
public static void main(String[] args) {
System.out.println(makeList());
}
} // fn dangling() -> &Vec<i32> {
// let local = vec![1, 2, 3];
// &local // COMPILE ERROR: 'local' is dropped at the end of this function,
// // so a reference to it would dangle — the borrow checker refuses this
// }
fn owning() -> Vec<i32> {
let local = vec![1, 2, 3];
local // return the VALUE itself — ownership moves to the caller, no dangling possible
}
fn main() {
println!("{:?}", owning());
} A Java method can freely return a reference to a locally created object because the garbage collector keeps that object alive for as long as any reference to it survives — dangling references are structurally impossible in Java. Rust achieves the same safety guarantee by a completely different route: the borrow checker refuses, at compile time, to let a reference outlive the value it points to. The fix shown here — returning the owned value itself rather than a reference to a local — is the standard idiom.
No Null — Option<T>
null and NullPointerException
class Main {
static String findUserName(int id) {
return null; // any reference type can hold null, with no compiler warning
}
public static void main(String[] args) {
String name = findUserName(1);
System.out.println(name.length()); // throws NullPointerException at RUNTIME
}
} fn find_user_name(_id: i32) -> Option<String> {
None // absence is a value, not a hidden hazard
}
fn main() {
let name = find_user_name(1);
// name.len(); // compile error: Option<String> has no method 'len'
println!("{}", name.map(|value| value.len()).unwrap_or(0));
} In Java, any reference-typed variable —
String, any object, any collection element — can silently hold null, and the compiler gives no warning; the failure only appears as a NullPointerException at runtime, often far from where the null was introduced. The Java panel is marked non-runnable because it deliberately demonstrates that crash. Rust has no null value in safe code at all: a type like String can never be absent. Absence must be represented explicitly with Option<T>, and the compiler refuses to compile code that uses the value without first checking whether it is present.Optional<T> is opt-in; null is still there
class Main {
static java.util.Optional<String> findUserName(int id) {
return java.util.Optional.empty(); // the safe API
}
static String findUserNameUnsafe(int id) {
return null; // nothing stops this method from existing right beside the safe one
}
public static void main(String[] args) {
// Optional is a wrapper you must choose to use — ordinary methods can
// still return a bare null, and Java code does this constantly.
String legacyResult = findUserNameUnsafe(1);
System.out.println(legacyResult.length()); // still throws NullPointerException
}
} fn find_user_name(_id: i32) -> Option<String> {
None // there is no "unsafe sibling" signature that returns a bare, unchecked value
}
fn main() {
let name = find_user_name(1);
match name {
Some(value) => println!("{value}"),
None => println!("(no user)"),
}
} Java 8's
Optional<T> is a genuine improvement, but it is opt-in — nothing in the language prevents a method from returning a bare, nullable String right beside another method that wraps its result in Optional, and most of the JDK and most existing Java code predates Optional entirely, so null remains pervasive underneath. The Java panel is marked non-runnable because findUserNameUnsafe deliberately triggers that crash to make the point concrete. Rust has no equivalent escape hatch: every function signature makes optionality explicit in its return type, and there is no "raw," unchecked alternative to reach for instead.Working with Option<T>
class Main {
public static void main(String[] args) {
java.util.Optional<String> name = java.util.Optional.of("Alice");
String displayName = name.orElse("(unknown)");
System.out.println(displayName);
java.util.Optional<Integer> length = name.map(String::length);
System.out.println(length.orElse(0));
}
} fn main() {
let name: Option<&str> = Some("Alice");
let display_name = name.unwrap_or("(unknown)");
println!("{display_name}");
let length = name.map(|text| text.len());
println!("{}", length.unwrap_or(0));
} Rust's
Option<T> API closely mirrors Java's Optional<T>: .unwrap_or(default) parallels .orElse(default), and .map(function) transforms the contained value in both. The crucial difference is not the API shape but the coverage: in Rust, Option<T> is the only way any value can be absent, so this API is unavoidable rather than optional.if let Some — conditional unwrapping
class Main {
public static void main(String[] args) {
java.util.Optional<Integer> value = java.util.Optional.of(42);
if (value.isPresent()) {
System.out.println("Got: " + value.get());
} else {
System.out.println("Nothing");
}
}
} fn main() {
let value: Option<i32> = Some(42);
if let Some(number) = value {
println!("Got: {number}");
} else {
println!("Nothing");
}
} Java's
Optional.isPresent() followed by .get() is a two-step check-then-extract pattern that the compiler cannot verify is used correctly — calling .get() on an empty Optional throws NoSuchElementException at runtime. Rust's if let Some(number) = value pattern-matches and binds the contained value in a single step; there is no separate unchecked extraction method to misuse.No Exceptions — Result<T, E>
Java checked exceptions vs no exceptions at all
class Main {
static int parseNumber(String input) throws NumberFormatException {
return Integer.parseInt(input); // unchecked — callers are not FORCED to handle it
}
public static void main(String[] args) {
try {
System.out.println(parseNumber("not a number"));
} catch (NumberFormatException error) {
System.out.println("Parse error: " + error.getMessage());
}
}
} fn parse_number(input: &str) -> Result<i32, std::num::ParseIntError> {
input.parse() // fallibility is part of the return type, not a side-channel
}
fn main() {
match parse_number("not a number") {
Ok(value) => println!("{value}"),
Err(error) => println!("Parse error: {error}"),
}
} Java has two exception categories: checked exceptions, which the compiler forces a caller to catch or declare in
throws, and unchecked (runtime) exceptions like NumberFormatException, which can propagate silently through any number of stack frames with no compiler involvement at all. Rust has neither category, because it has no exceptions: a fallible operation returns Result<T, E>, and the possibility of failure is visible in every signature along the call chain, with no unchecked escape hatch.Result<T, E> — the return-value error channel
class Main {
public static void main(String[] args) {
try {
int value = Integer.parseInt("not a number");
System.out.println(value);
} catch (NumberFormatException error) {
System.out.println("Parse error: " + error.getMessage());
}
}
} fn main() {
let result: Result<i32, _> = "not a number".parse();
match result {
Ok(value) => println!("{value}"),
Err(error) => println!("Parse error: {error}"),
}
} Java signals failure by throwing and unwinding the call stack, transferring control to whichever
catch block matches. Rust signals failure by returning a value — Result<T, E> is either Ok(value) or Err(error), an ordinary enum with no special control-flow behavior. Nothing "jumps" anywhere; the caller receives a Result and must inspect it like any other value.The ? operator — explicit propagation
class Main {
// Java: an exception thrown deep in parseNumber propagates automatically,
// with no marker at the call site — the signature doesn't even need
// 'throws' since NumberFormatException is unchecked.
static int parseAndDouble(String input) {
int value = Integer.parseInt(input); // may throw; call site has no visible marker
return value * 2;
}
public static void main(String[] args) {
System.out.println(parseAndDouble("5"));
}
} fn parse_and_double(input: &str) -> Result<i32, std::num::ParseIntError> {
let value: i32 = input.parse()?; // '?' propagates the Err immediately if parsing fails
Ok(value * 2)
}
fn main() {
println!("{:?}", parse_and_double("5"));
println!("{:?}", parse_and_double("bad"));
} Java's unchecked exceptions propagate invisibly — nothing at the call site of
Integer.parseInt(input) signals that the line might transfer control elsewhere. Rust requires the ? operator at every fallible call site: it is shorthand for "return this Err immediately if there is one, otherwise unwrap the Ok value." Propagation is deliberate and visible at every single step of the call chain, not an automatic, invisible unwind.panic! — the closest thing to an unchecked exception
class Main {
public static void main(String[] args) {
java.util.List<Integer> items = java.util.List.of(1, 2, 3);
System.out.println(items.get(10)); // throws IndexOutOfBoundsException — an unchecked, runtime exception
}
} fn main() {
let items = vec![1, 2, 3];
println!("{}", items[10]); // panics: index out of bounds — unrecoverable by default, just like Java's uncaught exceptions
} Both sides of this example are marked non-runnable because both deliberately crash: Java's uncaught
IndexOutOfBoundsException terminates the program, and Rust's panic does too. This is Rust's one concession to something exception-shaped: a panic! — triggered explicitly or by an operation like an out-of-bounds index — unwinds the stack and aborts, for conditions considered programmer bugs rather than expected failures. Unlike Java exceptions, panics are not typed, are not meant to be selectively caught, and there is no equivalent of a checked exception forcing you to handle one.Chaining with map and and_then
class Main {
public static void main(String[] args) {
try {
int value = Integer.parseInt("5");
String output = Integer.toString(value * 2);
System.out.println(output);
} catch (NumberFormatException error) {
System.out.println("error");
}
}
} fn main() {
let output: Result<String, _> = "5"
.parse::<i32>()
.map(|value| (value * 2).to_string());
println!("{output:?}"); // Ok("10")
} Java composes fallible steps with sequential statements inside a single
try block — any of them can throw, and the catch block handles whichever one did. Rust composes them functionally: Result::map(f) applies f only if the result is Ok, leaving Err untouched, and Result::and_then(f) does the same when f itself returns a Result. This builds an explicit pipeline of fallible steps without a shared, ambient try block.Structs & Methods
struct + impl vs class
class Main {
static class Point {
int x;
int y;
Point(int x, int y) { // constructor
this.x = x;
this.y = y;
}
double distance() {
return Math.sqrt(x * x + y * y);
}
}
public static void main(String[] args) {
Point point = new Point(3, 4);
System.out.println(point.distance());
}
} struct Point { x: i32, y: i32 } // data only — no methods live here
impl Point { // methods live in a SEPARATE impl block
fn distance(&self) -> f64 {
((self.x * self.x + self.y * self.y) as f64).sqrt()
}
}
fn main() {
let point = Point { x: 3, y: 4 }; // struct literal — no constructor call
println!("{}", point.distance());
} A Java
class bundles fields, constructors, and methods in one declaration. Rust separates data from behavior: a struct declares only fields, and any number of separate impl blocks attach methods to it. There is no constructor in the Java sense — a Point value is built directly with struct-literal syntax, Point { x: 3, y: 4 }, or through an ordinary associated function you define yourself (see the next concept).Associated functions instead of constructors
class Main {
static class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
static Point origin() { return new Point(0, 0); } // static factory method
}
public static void main(String[] args) {
Point point = new Point(3, 4);
Point zero = Point.origin();
System.out.println(zero.x + "," + zero.y);
}
} struct Point { x: i32, y: i32 }
impl Point {
fn new(x: i32, y: i32) -> Point { // convention, not a language keyword — 'new' is an ordinary name
Point { x, y }
}
fn origin() -> Point { // associated function — no 'self' parameter, called with ::
Point { x: 0, y: 0 }
}
}
fn main() {
let point = Point::new(3, 4);
let zero = Point::origin();
println!("{},{}", zero.x, zero.y);
} Java constructors are a distinct language construct, invoked with
new ClassName(...). Rust has no constructor syntax at all — new is just a conventional function name, not a keyword. Any function inside an impl block that does not take self as its first parameter is an "associated function," called with Type::function(...), exactly like Java's static factory methods (Point.origin()).&self, &mut self, and self
class Main {
static class Counter {
int count = 0;
int getCount() { return count; } // reads 'this' implicitly
void increment() { count++; } // mutates 'this' implicitly — Java makes no distinction
}
public static void main(String[] args) {
Counter counter = new Counter();
counter.increment();
System.out.println(counter.getCount());
}
} struct Counter { count: i32 }
impl Counter {
fn get_count(&self) -> i32 { // &self: read-only borrow of the receiver
self.count
}
fn increment(&mut self) { // &mut self: mutable borrow — required to change a field
self.count += 1;
}
}
fn main() {
let mut counter = Counter { count: 0 };
counter.increment();
println!("{}", counter.get_count());
} Java's implicit
this is always mutable and never distinguishes a read-only method from a mutating one at the type level. Rust methods declare their receiver explicitly: &self borrows the instance read-only, &mut self borrows it mutably (required for any method that changes a field), and plain self (rare) consumes the instance entirely. The borrow-checker rules from the ownership sections apply to self exactly as they do to any other reference.#[derive(...)] vs equals/hashCode/toString
class Main {
record Point(int x, int y) {} // Java records auto-generate equals, hashCode, toString
public static void main(String[] args) {
Point pointA = new Point(3, 4);
Point pointB = new Point(3, 4);
System.out.println(pointA); // Point[x=3, y=4]
System.out.println(pointA.equals(pointB)); // true
}
} #[derive(Debug, PartialEq, Clone, Copy)] // auto-generates Debug, ==, and cheap copying
struct Point { x: i32, y: i32 }
fn main() {
let point_a = Point { x: 3, y: 4 };
let point_b = Point { x: 3, y: 4 };
println!("{point_a:?}"); // Point { x: 3, y: 4 }
println!("{}", point_a == point_b); // true
} Java records (since Java 16) auto-generate
equals, hashCode, and toString for a data-only type — a close parallel to Rust's #[derive(...)] attribute, which auto-implements traits like Debug (developer-readable printing) and PartialEq (== comparison) for a plain struct. Rust's derive list is more granular and extends beyond equality/printing to traits like Clone, Copy, Hash, and Ord, each opted into independently.Traits vs Interfaces
A trait looks like an interface
class Main {
interface Greetable {
String name();
default String greet() { return "Hello, " + name() + "!"; } // default method, since Java 8
}
record Person(String name) implements Greetable {}
public static void main(String[] args) {
Person person = new Person("Alice");
System.out.println(person.greet());
}
} trait Greetable {
fn name(&self) -> String;
fn greet(&self) -> String { // default method — same idea as Java's 'default'
format!("Hello, {}!", self.name())
}
}
struct Person { name: String }
impl Greetable for Person {
fn name(&self) -> String { self.name.clone() }
}
fn main() {
let person = Person { name: String::from("Alice") };
println!("{}", person.greet());
} A Rust
trait with a default method reads almost identically to a Java interface with a default method — both declare required methods plus optional ones with a shared implementation. The syntax difference is structural: Java's implements appears on the class declaration, while Rust's impl Trait for Type is a separate block, which is what enables the next concept — implementing a trait on a type you do not own.Implementing a trait on a foreign type
class Main {
// Java: you CANNOT make java.lang.String implement a new interface —
// it is final and you don't own it. The only workaround is a wrapper
// class or a static utility method called separately.
static boolean isPalindrome(String input) {
return input.equals(new StringBuilder(input).reverse().toString());
}
public static void main(String[] args) {
System.out.println(isPalindrome("racecar")); // called as a free function, not a method
}
} trait Palindrome {
fn is_palindrome(&self) -> bool;
}
impl Palindrome for str { // implementing OUR trait on Rust's BUILT-IN str type — legal!
fn is_palindrome(&self) -> bool {
self.chars().eq(self.chars().rev())
}
}
fn main() {
println!("{}", "racecar".is_palindrome()); // called AS A METHOD, as if str had it natively
} This has no equivalent in Java at all. Java's
String is final and you do not own it, so you cannot make it implement a new interface — a static utility method (isPalindrome(str)) is the only option, and it never reads like a method call on the string itself. Rust allows implementing your own trait on any type, including built-in types like str or types from other crates, as long as either the trait or the type is defined in your own code (the "orphan rule"). This is genuinely new territory for a Java developer, and it is how much of Rust's idiomatic ergonomics — extension-method-like APIs on foreign types — are built.Trait bounds on generic functions
class Main {
static <T extends Comparable<T>> T maximum(T first, T second) {
return first.compareTo(second) >= 0 ? first : second;
}
public static void main(String[] args) {
System.out.println(maximum(42, 17));
System.out.println(maximum("apple", "banana"));
}
} fn maximum<T: PartialOrd>(first: T, second: T) -> T {
if first >= second { first } else { second }
}
fn main() {
println!("{}", maximum(42, 17));
println!("{}", maximum("apple", "banana"));
} Java's
<T extends Comparable<T>> and Rust's <T: PartialOrd> serve the same purpose — restricting a generic type parameter to types that support a particular operation. Rust also supports a more readable where-clause form for complex bounds: fn maximum<T>(first: T, second: T) -> T where T: PartialOrd, useful when a function has several type parameters with several bounds each.Trait objects — dyn Trait
class Main {
interface Shape { double area(); }
record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
record Square(double side) implements Shape {
public double area() { return side * side; }
}
public static void main(String[] args) {
java.util.List<Shape> shapes = java.util.List.of(new Circle(2.0), new Square(3.0));
for (Shape shape : shapes) {
System.out.printf("%.2f%n", shape.area());
}
}
} trait Shape { fn area(&self) -> f64; }
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } }
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } }
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 })];
for shape in &shapes {
println!("{:.2}", shape.area());
}
} A Java
List<Shape> holding mixed Circle and Square instances uses ordinary runtime polymorphism — every object reference already carries type information. Rust achieves the same runtime-polymorphic collection with Vec<Box<dyn Shape>>: dyn Shape is a "trait object" resolved via a vtable at runtime (like a Java interface reference), and Box heap-allocates each value since a dyn Shape has no fixed compile-time size on its own. This is the deliberately-opted-into dynamic-dispatch counterpart to the compile-time impl Trait/generic style used elsewhere in this cheatsheet.Enums with Associated Data
Plain enums — the familiar part
class Main {
enum Direction { NORTH, SOUTH, EAST, WEST }
public static void main(String[] args) {
Direction heading = Direction.NORTH;
System.out.println(heading);
}
} #[derive(Debug)]
enum Direction { North, South, East, West }
fn main() {
let heading = Direction::North;
println!("{heading:?}");
} A plain, dataless enum works almost identically in both languages — a fixed set of named values, accessed via the type name (
Direction::North in Rust, Direction.NORTH in Java). The #[derive(Debug)] attribute is needed for the {:?} print format, since Rust enums do not automatically implement a string representation the way Java's enum inherits toString() from java.lang.Enum.Enum variants carrying different data
class Main {
// Java's plain enum CANNOT attach different fields to different constants
// without significant machinery (an abstract method per constant, or a
// sealed interface + records, shown in the next concept).
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static double area(Shape shape) {
return switch (shape) {
case Circle circle -> Math.PI * circle.radius() * circle.radius();
case Rectangle rectangle -> rectangle.width() * rectangle.height();
};
}
public static void main(String[] args) {
System.out.printf("%.2f%n", area(new Circle(2.0)));
}
} enum Shape {
Circle(f64), // one variant carries a single f64 payload
Rectangle(f64, f64), // another variant carries a DIFFERENT shape of payload
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
}
}
fn main() {
println!("{:.2}", area(&Shape::Circle(2.0)));
} This is where Rust enums leave Java's
enum far behind: each variant of a Rust enum can carry its own distinct payload shape — Circle(f64) carries one number, Rectangle(f64, f64) carries two — all under a single type, Shape. Java's enum constants share one fixed set of fields across every constant; there is no way to give CIRCLE a radius field and RECTANGLE a width/height pair on the same enum. A true Rust-style algebraic data type in Java requires the sealed-interface-plus-records pattern shown in the next concept.Java 21+ sealed interfaces as the bridge
class Main {
// Java 17+ sealed interfaces + records + Java 21 exhaustive switch pattern
// matching CLOSE much of the gap with Rust enums — but it is three
// separate language features working together, not one built-in construct.
sealed interface Result<T> permits Success, Failure {}
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String message) implements Result<T> {}
static <T> void show(Result<T> result) {
switch (result) {
case Success<T> success -> System.out.println("OK: " + success.value());
case Failure<T> failure -> System.out.println("Error: " + failure.message());
// no 'default' needed — the compiler knows Success/Failure are the only permitted cases
}
}
public static void main(String[] args) {
show(new Success<>("data"));
show(new Failure<>("not found"));
}
} #[derive(Debug)]
enum ApiResult<T> { // ONE built-in construct expresses the whole thing directly
Success(T),
Error(String),
}
fn show<T: std::fmt::Debug>(result: ApiResult<T>) {
match result {
ApiResult::Success(value) => println!("OK: {value:?}"),
ApiResult::Error(message) => println!("Error: {message}"),
}
}
fn main() {
show(ApiResult::Success("data"));
// The Error variant carries no T, so the compiler cannot infer it from the
// argument alone — the turbofish ::<&str> pins T explicitly.
show(ApiResult::<&str>::Error(String::from("not found")));
} Java 17's sealed interfaces (restricting which types may implement an interface) combined with records and Java 21's exhaustive pattern-matching
switch genuinely close most of the practical gap with Rust enums — the same "the compiler proves I handled every case" guarantee is achievable in modern Java. The remaining difference is ergonomic and structural rather than a missing capability: Rust expresses the whole algebraic data type as one enum declaration, while Java assembles the same idea from three separate features (a sealed interface, one record per variant, and pattern-matching switch). The turbofish (::<&str>) on the second call is required because the Error variant does not mention T, so nothing in that call lets the compiler infer it — Java's new Failure<>("not found") has no analogous inference gap since Failure<T> is a distinct type per generic instantiation, not a shared variant of one enum.Pattern Matching
match vs switch expression
class Main {
public static void main(String[] args) {
int day = 3;
String name = switch (day) {
case 1 -> "Monday";
case 2 -> "Tuesday";
case 3 -> "Wednesday";
default -> "Other";
};
System.out.println(name);
}
} fn main() {
let day = 3;
let name = match day {
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
_ => "Other",
};
println!("{name}");
} Java's modern switch expression and Rust's
match read almost identically for simple value matching. Rust uses _ as the wildcard arm where Java uses default. The bigger difference shows up on non-trivial types: Rust match is exhaustive over every enum, always, with no sealed keyword required to opt in — exhaustiveness is simply how enums work in Rust.Compiler-enforced exhaustiveness
class Main {
sealed interface TrafficLight permits Red, Yellow, Green {}
record Red() implements TrafficLight {}
record Yellow() implements TrafficLight {}
record Green() implements TrafficLight {}
// If a new record 'Flashing' is added to 'permits' but this switch is not
// updated, the compiler DOES catch it — but only because 'sealed' was used.
// An ordinary Java interface would compile this switch just fine, silently
// missing the new case.
static String action(TrafficLight light) {
return switch (light) {
case Red red -> "Stop";
case Yellow yellow -> "Caution";
case Green green -> "Go";
};
}
public static void main(String[] args) {
System.out.println(action(new Red()));
}
} enum TrafficLight { Red, Yellow, Green }
fn action(light: &TrafficLight) -> &'static str {
match light {
TrafficLight::Red => "Stop",
TrafficLight::Yellow => "Caution",
TrafficLight::Green => "Go",
// Adding a new variant to TrafficLight forces a compile ERROR here,
// automatically, with no 'sealed' opt-in needed — it is the default.
}
}
fn main() {
println!("{}", action(&TrafficLight::Red));
} Java only gets compiler-enforced exhaustiveness if the type hierarchy is explicitly marked
sealed — an ordinary interface or a non-final class hierarchy compiles a switch missing a case without complaint, silently leaving a gap that surfaces at runtime. In Rust, every enum is implicitly closed — you cannot add a variant from outside the module without changing the enum declaration itself — so match exhaustiveness checking applies universally, with no opt-in keyword and no way to accidentally skip it.Destructuring in match arms
class Main {
record Point(int x, int y) {}
public static void main(String[] args) {
Point point = new Point(0, 5);
String description = switch (point) {
case Point(var x, var y) when x == 0 && y == 0 -> "origin";
case Point(var x, var y) when x == 0 -> "on the y-axis";
case Point(var x, var y) when y == 0 -> "on the x-axis";
case Point p -> "elsewhere";
};
System.out.println(description);
}
} struct Point { x: i32, y: i32 }
fn describe(point: &Point) -> &'static str {
match (point.x, point.y) {
(0, 0) => "origin",
(0, _) => "on the y-axis",
(_, 0) => "on the x-axis",
_ => "elsewhere",
}
}
fn main() {
let point = Point { x: 0, y: 5 };
println!("{}", describe(&point));
} Java 21's record patterns with
when guards can express the same destructuring logic as Rust's tuple/struct patterns, but Rust's wildcard _ inside a pattern (matching "any value, don't bind it") is more concise than Java's repeated when guard clauses for the same purpose. Rust patterns can also destructure directly in let bindings, function parameters, and for loops — not only inside match.Range patterns and match guards
class Main {
public static void main(String[] args) {
int score = 82;
String grade = switch (Integer.valueOf(score)) {
case Integer value when value >= 90 -> "A";
case Integer value when value >= 80 -> "B";
case Integer value when value >= 70 -> "C";
default -> "F";
};
System.out.println(grade);
}
} fn main() {
let score = 82;
let grade = match score {
90..=100 => "A", // inclusive range pattern — no guard needed for this shape
80..=89 => "B",
70..=79 => "C",
_ => "F",
};
println!("{grade}");
} Java 21 pattern matching requires a
when guard clause to express a numeric range comparison inside a switch. Rust has first-class inclusive range patterns (90..=100) that need no guard at all for this common shape; a Rust match guard (score if score % 2 == 0 => ...) is reserved for conditions a pattern alone cannot express, such as comparing two bound variables to each other.Generics
Type erasure vs monomorphization
class Main {
static <T> void printTypeInfo(java.util.List<T> items) {
// At RUNTIME, Java has already erased T — every List<T> is just
// "ArrayList" here, regardless of what T actually was at compile time.
System.out.println(items.getClass().getSimpleName()); // always "ImmutableCollections$ListN" or similar — T is gone
}
public static void main(String[] args) {
printTypeInfo(java.util.List.of("hello", "world"));
printTypeInfo(java.util.List.of(1, 2, 3));
}
} fn print_type_info<T>(items: &Vec<T>) {
// The COMPILER generates a separate, specialized version of this function
// for every distinct T it is called with — there is nothing to "erase".
println!("{} items", items.len());
}
fn main() {
print_type_info(&vec!["hello", "world"]); // compiles a Vec<&str> specialization
print_type_info(&vec![1, 2, 3]); // compiles a SEPARATE Vec<i32> specialization
} Java generics are compiled away entirely by type erasure: at runtime,
List<String> and List<Integer> are both plain List, with no way to recover the type argument via reflection — this is the source of Java's notorious "unchecked cast" warnings. Rust generics use monomorphization instead: the compiler generates a completely separate, specialized copy of the function or type for every distinct type argument it is used with, so there is no erasure and no runtime type-information loss. The tradeoff is larger compiled binaries (one copy per instantiation) in exchange for zero runtime overhead — generic code runs exactly as fast as if it had been hand-written for that one specific type.A generic struct
class Main {
static class Stack<T> {
private final java.util.ArrayList<T> items = new java.util.ArrayList<>();
void push(T item) { items.add(item); }
T pop() { return items.remove(items.size() - 1); }
boolean isEmpty() { return items.isEmpty(); }
}
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
System.out.println(stack.pop());
}
} struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
fn new() -> Stack<T> {
Stack { items: Vec::new() }
}
fn push(&mut self, item: T) {
self.items.push(item);
}
fn pop(&mut self) -> Option<T> { // no exception for an empty stack — Option makes it explicit
self.items.pop()
}
}
fn main() {
let mut stack: Stack<i32> = Stack::new();
stack.push(1);
stack.push(2);
println!("{:?}", stack.pop());
} A generic Rust
struct<T> alongside a generic impl<T> block closely mirrors a Java generic class. The more instructive difference is the return type of pop(): the Java version throws an unchecked exception on an empty stack (or requires a manual empty-check first), while the Rust version returns Option<T> — None for an empty stack rather than a runtime crash, following the "no null, no exceptions" theme running through this whole cheatsheet.Multiple trait bounds
class Main {
static <T extends Comparable<T> & java.io.Serializable> T maximum(T first, T second) {
return first.compareTo(second) >= 0 ? first : second;
}
public static void main(String[] args) {
System.out.println(maximum(42, 17));
}
} use std::fmt::Debug;
fn maximum<T: PartialOrd + Debug>(first: T, second: T) -> T {
if first >= second { first } else { second }
}
fn main() {
println!("{:?}", maximum(42, 17));
} Java combines multiple bounds on a type parameter with
&: <T extends Comparable<T> & Serializable>. Rust uses + for the same purpose: <T: PartialOrd + Debug> requires T to implement both traits. Both mechanisms exist to let a generic function call methods from more than one contract on its type parameter.Collections
Vec<T> vs ArrayList<T>
class Main {
public static void main(String[] args) {
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(10, 20, 30));
numbers.add(40);
System.out.println(numbers.get(0));
System.out.println(numbers.size());
}
} fn main() {
let mut numbers = vec![10, 20, 30];
numbers.push(40);
println!("{}", numbers[0]);
println!("{}", numbers.len());
} Vec<T> is Rust's equivalent of ArrayList<T> — a heap-allocated, growable, contiguous sequence. Because Rust has no type erasure, Vec<i32> stores unboxed integers directly, with none of the boxing overhead Java's ArrayList<Integer> pays for every element. The vec![] macro is the idiomatic literal syntax, and .push/.len map directly to .add/.size().HashMap<K, V> in both languages
class Main {
public static void main(String[] args) {
java.util.Map<String, Integer> scores = new java.util.HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 82);
System.out.println(scores.get("Alice"));
System.out.println(scores.getOrDefault("Charlie", 0));
}
} use std::collections::HashMap;
fn main() {
let mut scores: HashMap<&str, i32> = HashMap::new();
scores.insert("Alice", 95);
scores.insert("Bob", 82);
println!("{}", scores["Alice"]);
println!("{}", scores.get("Charlie").copied().unwrap_or(0));
} The two
HashMaps share a name and behave the same way conceptually. Java's .put(key, value)/.get(key) become Rust's .insert(key, value)/index or .get(key); Rust's .get() returns Option<&V> rather than null, so .copied().unwrap_or(default) is the direct analog of .getOrDefault(key, default).Entry API — insert-or-update in one lookup
class Main {
public static void main(String[] args) {
String[] words = { "apple", "banana", "apple", "cherry", "banana", "apple" };
java.util.Map<String, Integer> wordCounts = new java.util.HashMap<>();
for (String word : words) {
wordCounts.merge(word, 1, Integer::sum); // insert-or-update in one call
}
System.out.println(wordCounts.get("apple"));
}
} use std::collections::HashMap;
fn main() {
let words = ["apple", "banana", "apple", "cherry", "banana", "apple"];
let mut word_counts: HashMap<&str, i32> = HashMap::new();
for word in &words {
*word_counts.entry(word).or_insert(0) += 1;
}
println!("{}", word_counts["apple"]);
} Java's
Map.merge(key, value, function) and Rust's .entry(key).or_insert(default) both perform an insert-or-update with a single hash lookup, avoiding the classic double-lookup of checking presence and then inserting or updating separately. Rust's entry API returns a mutable reference you dereference with * to modify in place, which reads less like a one-liner but composes with any update logic, not only summation.Fixed-size arrays
class Main {
public static void main(String[] args) {
int[] scores = { 85, 92, 78, 95 }; // fixed length, but Java arrays are still heap objects
System.out.println(scores[0]);
System.out.println(scores.length);
}
} fn main() {
let scores: [i32; 4] = [85, 92, 78, 95]; // the size 4 is part of the TYPE itself
println!("{}", scores[0]);
println!("{}", scores.len());
} Both languages have fixed-size arrays, but Rust's
[T; N] bakes the length into the type — [i32; 4] and [i32; 5] are distinct types, and the length is known and checked at compile time. Java arrays are always heap-allocated objects with a runtime-checked length property; Rust arrays are typically stack-allocated (unless boxed), giving them performance characteristics closer to C arrays. Use Vec<T>, not [T; N], whenever the size is not known until runtime.Closures & Iterators
Lambda vs closure syntax
class Main {
public static void main(String[] args) {
java.util.function.Function<Integer, Integer> doubleIt = x -> x * 2;
System.out.println(doubleIt.apply(5));
}
} fn main() {
let double_it = |x: i32| x * 2;
println!("{}", double_it(5));
} Java lambdas implement a functional interface (
Function<T, R>, Supplier<T>, etc.) and are invoked through that interface's single abstract method (.apply(...)). Rust closures use |params| body syntax and are called directly like a regular function, double_it(5) — no interface, no .apply indirection.Capture semantics — effectively final vs move
class Main {
public static void main(String[] args) {
int baseValue = 10; // must be "effectively final" — Java forbids reassigning a captured local
java.util.function.Function<Integer, Integer> addBase = x -> x + baseValue;
System.out.println(addBase.apply(5)); // 15
}
} fn main() {
let base_value = 10;
let add_base = |x| x + base_value; // borrows base_value by default
println!("{}", add_base(5)); // 15
// prefix with 'move' to force taking ownership instead of borrowing:
let owned_closure = move |x: i32| x + base_value;
println!("{}", owned_closure(5));
} Java requires a captured local variable to be "effectively final" — never reassigned after initialization — because the lambda captures its value, copying it in. Rust closures capture by the least-restrictive method that still compiles: borrowing first, then mutable borrowing, then moving. The
move keyword forces ownership to transfer into the closure, which is required whenever the closure must outlive the function it was created in — for example, when spawning a thread, covered later in this cheatsheet.Streams vs iterator chains
class Main {
public static void main(String[] args) {
java.util.List<Integer> numbers = java.util.List.of(1, 2, 3, 4, 5, 6);
int sumOfEvenSquares = numbers.stream()
.filter(n -> n % 2 == 0)
.mapToInt(n -> n * n)
.sum();
System.out.println(sumOfEvenSquares);
}
} fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
let sum_of_even_squares: i32 = numbers.iter()
.filter(|&&n| n % 2 == 0)
.map(|&n| n * n)
.sum();
println!("{sum_of_even_squares}");
} Java Streams and Rust iterator chains solve the same problem with a strikingly similar shape: lazy, chainable
.filter()/.map() operations that only run when a terminal operation (.sum(), .collect()) consumes them. The underlying cost model differs: a Java Stream boxes primitives when crossing generic boundaries and its lambdas are heap-allocated closures, while Rust's iterator adapters are zero-cost — the compiler inlines and monomorphizes the whole chain into code as fast as a hand-written loop, with no allocation for the closures themselves.Common iterator adapters
class Main {
public static void main(String[] args) {
java.util.List<Integer> numbers = java.util.List.of(1, 2, 3, 4, 5);
System.out.println(numbers.stream().mapToInt(Integer::intValue).sum());
System.out.println(numbers.stream().anyMatch(n -> n > 4));
System.out.println(numbers.stream().allMatch(n -> n > 0));
System.out.println(numbers.stream().filter(n -> n % 2 == 0).findFirst().orElse(-1));
}
} fn main() {
let numbers = vec![1, 2, 3, 4, 5];
println!("{}", numbers.iter().sum::<i32>());
println!("{}", numbers.iter().any(|&n| n > 4));
println!("{}", numbers.iter().all(|&n| n > 0));
println!("{}", numbers.iter().find(|&&n| n % 2 == 0).copied().unwrap_or(-1));
} Java Stream methods
.anyMatch, .allMatch, and .findFirst map directly onto Rust's .any(), .all(), and .find(). Java's Optional-returning .findFirst() parallels Rust's Option-returning .find() — both make "might not find anything" explicit in the return type rather than throwing or returning a sentinel value.Fearless Concurrency
Spawning threads
class Main {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("Working in a thread");
});
worker.start();
worker.join();
}
} use std::thread;
fn main() {
let worker = thread::spawn(|| {
println!("Working in a thread");
});
worker.join().unwrap();
} Java's
Thread and Rust's thread::spawn look nearly identical at this surface level — both hand a closure/lambda to a new OS thread and both support joining. The difference appears the moment the thread touches shared mutable state, covered next: Java lets you write a data race that compiles fine and fails unpredictably at runtime, while Rust refuses to compile the equivalent code at all.Data races are a compile error, not a runtime risk
class Main {
public static void main(String[] args) throws InterruptedException {
// Java: nothing stops two threads from touching 'counter' unsynchronized.
// This COMPILES and RUNS, but the result is nondeterministic — a
// textbook data race the compiler does not catch.
int[] counter = { 0 }; // array wrapper to work around effectively-final capture
Runnable increment = () -> { for (int i = 0; i < 1000; i++) counter[0]++; };
Thread threadA = new Thread(increment);
Thread threadB = new Thread(increment);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(counter[0]); // often NOT 2000 — a race, and it compiled fine
}
} use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0)); // Arc: shared ownership; Mutex: exclusive access
let mut handles = vec![];
for _ in 0..2 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1; // the ONLY way to touch the value — enforced by the type system
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // always 2000 — the race is impossible, not just unlikely
} This is "fearless concurrency," Rust's signature claim, made concrete: the unsynchronized Java version on the left compiles without a single warning and produces a nondeterministic result at runtime — a data race the type system never saw. The Rust version could not express the same mistake even if you tried: sharing mutable state across threads requires
Arc (atomic reference counting, for shared ownership) wrapping a Mutex (for exclusive access), and the borrow checker refuses to compile any access to the inner value that does not go through .lock(). The same ownership and borrowing rules that apply within a single thread extend across threads, so a whole category of concurrency bugs becomes a compile-time error instead of an occasional, hard-to-reproduce test failure.Channels vs BlockingQueue
class Main {
public static void main(String[] args) throws InterruptedException {
var queue = new java.util.concurrent.LinkedBlockingQueue<String>();
Thread producer = new Thread(() -> {
queue.add("hello from the producer thread");
});
producer.start();
producer.join();
System.out.println(queue.take());
}
} use std::sync::mpsc;
use std::thread;
fn main() {
let (sender, receiver) = mpsc::channel(); // multi-producer, single-consumer channel
thread::spawn(move || {
sender.send(String::from("hello from the producer thread")).unwrap();
});
println!("{}", receiver.recv().unwrap());
} Java's
BlockingQueue and Rust's std::sync::mpsc::channel both let threads communicate by passing ownership of messages rather than sharing mutable memory — "share memory by communicating" is a design philosophy Rust inherited partly from Go. The Rust compiler additionally enforces that a value sent through a channel is moved into the channel: the sending thread can no longer access it afterward, so there is no risk of the sender and receiver both mutating the same message concurrently.Gotchas for Java Programmers
The borrow checker rejecting "obviously fine" code
class Main {
public static void main(String[] args) {
// Java: this pattern — hold a reference into a collection while also
// mutating the collection — compiles and often even runs, though it
// can throw ConcurrentModificationException at runtime if you're unlucky.
java.util.List<Integer> numbers = new java.util.ArrayList<>(java.util.List.of(1, 2, 3));
int first = numbers.get(0);
numbers.add(4); // Java allows this without complaint
System.out.println(first);
}
} fn main() {
let mut numbers = vec![1, 2, 3];
let first = &numbers[0]; // an immutable borrow into the vector
numbers.push(4); // COMPILE ERROR: cannot borrow 'numbers' as mutable
// because it is also borrowed as immutable (via 'first')
println!("{first}");
} The Rust side is deliberately left non-runnable — the whole point is that this code does not compile. This is the single most common early frustration moving from Java to Rust: code that "obviously" works — reading an old reference while appending to the same vector — is routinely rejected by the borrow checker, because
numbers.push(4) might reallocate the vector's backing storage, which would leave first pointing at freed memory. Java's garbage collector sidesteps this entire class of bug at the cost of never catching it at compile time; Rust catches it every time, but expect a real learning curve reworking code the compiler rejects, especially in the first few weeks.No inheritance — composition and traits only
class Main {
static class Animal {
String name;
Animal(String name) { this.name = name; }
String speak() { return name + " makes a sound"; }
}
static class Dog extends Animal { // Java "is-a" modeling: Dog IS an Animal
Dog(String name) { super(name); }
@Override String speak() { return name + " barks"; }
}
public static void main(String[] args) {
Animal animal = new Dog("Rex");
System.out.println(animal.speak());
}
} trait Speak {
fn speak(&self) -> String;
}
struct Dog { name: String } // Dog does NOT extend anything — there is no base 'Animal' struct to inherit from
impl Speak for Dog {
fn speak(&self) -> String { format!("{} barks", self.name) }
}
fn main() {
let dog = Dog { name: String::from("Rex") };
println!("{}", dog.speak());
} Rust has no
extends, no base classes, and no "is-a" relationships at all — a Java developer's habit of reaching for a class hierarchy to share behavior does not transfer directly. Shared behavior in Rust comes from implementing the same trait on unrelated types (what Dog and Cat have in common is that both implement Speak, not that both extend Animal) and shared data comes from composition — a struct holding another struct as a field — rather than a base-class field being inherited. This is a genuine redesign exercise for anything modeled as a deep Java class hierarchy, not a syntax swap.Explicit propagation at every call site
class Main {
// Java: an exception from readFile() can propagate through TEN nested
// calls with zero markers at any of the intermediate call sites.
static String readFile(String path) throws java.io.IOException {
return java.nio.file.Files.readString(java.nio.file.Path.of(path));
}
static String loadConfig() throws java.io.IOException {
return readFile("config.txt"); // 'throws' is the only hint that this can fail
}
public static void main(String[] args) {
try {
System.out.println(loadConfig());
} catch (java.io.IOException error) {
System.out.println("Could not load config: " + error.getMessage());
}
}
} use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
fn load_config() -> Result<String, io::Error> {
let contents = read_file("config.txt")?; // '?' is required HERE, explicitly, every single time
Ok(contents)
}
fn main() {
match load_config() {
Ok(contents) => println!("{contents}"),
Err(error) => println!("Could not load config: {error}"),
}
} Both sides are marked non-runnable since
config.txt does not exist in this sandboxed environment — the interesting part is the shape of the code, not its output. Java exceptions, even checked ones, still bubble up through the call stack automatically once thrown — a developer only sees a marker (throws) at each intermediate function, not an action. Rust requires the ? operator at the exact call site of every fallible operation, all the way up the chain; there is no automatic bubbling. This is more verbose but leaves nothing implicit — you can always see precisely where a failure can originate and where it is handled, at the cost of typing ? far more often than throws appears in equivalent Java.Compile times and the "rejected, not crashed" mindset
class Main {
public static void main(String[] args) {
// Java: javac is fast, and a huge category of bugs (null derefs, data
// races, use-after-free — none of which apply to Java anyway) are
// simply not caught by the compiler; they show up as RUNTIME failures,
// sometimes in production, sometimes only under load.
System.out.println("Compiles fast; many failure modes surface at runtime instead.");
}
} fn main() {
// Rust: rustc is considerably slower than javac, especially on a clean
// build or when generics/trait resolution get complex — the tradeoff for
// catching entire bug categories (data races, use-after-free, null derefs)
// BEFORE the program ever runs, rather than during a production incident.
println!("Compiles slower; the same failure modes are rejected before runtime instead.");
} Expect Rust compile times — particularly clean builds and builds that stress generics or trait resolution — to be noticeably longer than the equivalent
javac invocation, and expect that tradeoff to be worth it: the entire class of runtime failure Java accepts as a cost of doing business (null dereferences, data races, use-after-free, none of which are possible in safe Rust at all) is instead caught during that longer compile. The mental shift this requires is real: a Java developer used to "it compiled, let's see if it works" has to adjust to "if it compiles, several categories of bug are already provably absent" — and, correspondingly, to spending more time satisfying the compiler up front rather than debugging failures after deployment.