PONYλM2Modula-2

Rust.CodeCompared.To/Java

An interactive executable cheatsheet comparing Rust and Java

Rust 1.97.1 Java 25
Hello World & Tooling
Hello, World
Java has no free functions, so the entry point cannot be a bare main — it has to live inside a class. The signature is fixed: public static void main(String[] args), and the runtime looks for exactly that.
fn main() { println!("Hello, World!"); }
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
Nothing in Java lives outside a class. System.out is a static field holding a PrintStream, and println is an ordinary method call on it — where println! is a macro that the Rust compiler expands and type-checks at the call site. Java has no macros at all, which is why the rest of this page reaches for reflection and annotations where Rust would reach for derive.
Command-line arguments
The off-by-one that catches every Rust programmer once: std::env::args() yields the program path as element zero, and Java's args does not.
use std::env; fn main() { let arguments: Vec<String> = env::args().collect(); println!("count including program: {}", arguments.len()); println!("program path: {}", arguments[0]); let user_arguments = &arguments[1..]; println!("user arguments: {}", user_arguments.len()); }
class Main { public static void main(String[] args) { System.out.println("count of user arguments: " + args.length); // There is no args[0] holding the program path — the JVM does not // put it there. The class that was launched is the closest thing: System.out.println("entry class: " + Main.class.getName()); for (String argument : args) { System.out.println("argument: " + argument); } } }
Java's args contains only what the user typed, so args.length is already the user-argument count and args[0] is the first real argument rather than the executable. Both programs print zero user arguments here because neither runner passes any.
Cargo vs Maven and Gradle
Cargo ships with Rust and there is only one of it. Java's build tool is a separate project, there are two in common use, and the directory layout is load-bearing rather than conventional.
# Cargo.toml — one file, and it is the whole build definition. [package] name = "greeter" version = "0.1.0" edition = "2024" [dependencies] serde = { version = "1", features = ["derive"] } # cargo build / cargo test / cargo run / cargo fmt / cargo clippy # src/main.rs is the binary crate root; src/lib.rs the library crate root.
<!-- pom.xml — Maven. The build tool is not part of the JDK, and there are two mainstream ones (Maven and Gradle) rather than one Cargo. --> <project> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>greeter</artifactId> <version>0.1.0</version> <properties> <maven.compiler.release>25</maven.compiler.release> </properties> <dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.20.0</version> </dependency> </dependencies> </project> <!-- mvn compile / mvn test / mvn package Sources live under src/main/java, tests under src/test/java, and the directory tree must mirror the package name: com.example.greeter goes in src/main/java/com/example/greeter/. -->
The habit to unlearn is that a package name is free-form: in Java the package declaration must match the directory path, so package com.example.greeter; only compiles from com/example/greeter/. Dependencies come from Maven Central rather than crates.io, and are identified by a group and artifact pair instead of a single name. Neither column runs — these are build files, not programs.
Formatted output
Java's printf is the C family's, with %s, %d and %f placeholders that name a type. Rust's {} names no type because Display already knows how to render the value.
fn main() { let name = "Ada"; let score = 91.5_f64; // Inline captured identifiers, checked at compile time. println!("{name} scored {score:.1}"); // Width and alignment use the same mini-language. println!("[{name:>8}]"); println!("[{:04}]", 42); let rendered = format!("{name}: {score:.2}"); println!("{rendered}"); }
class Main { public static void main(String[] args) { String name = "Ada"; double score = 91.5; // No captured identifiers — every value is a trailing argument, // and the format string is checked at run time, not compile time. System.out.printf("%s scored %.1f%n", name, score); System.out.printf("[%8s]%n", name); System.out.printf("[%04d]%n", 42); String rendered = "%s: %.2f".formatted(name, score); System.out.println(rendered); } }
The real difference is when the check happens. Passing an int to %s and forgetting an argument are both run-time IllegalFormatExceptions in Java, while println! is a macro that rejects the same mistakes before the program exists. Note %n rather than \n — it emits the platform line separator, which is what println uses too.
Variables & Types
Immutable by default, or not
The defaults are exactly inverted. Rust makes you write mut to allow reassignment; Java makes you write final to forbid it.
fn main() { let total = 10; // immutable — the default let mut counter = 0; // opt IN to mutation counter += total; println!("counter = {counter}"); // total = 11; // error[E0384]: cannot assign twice to immutable variable println!("total is still {total}"); }
class Main { public static void main(String[] args) { int total = 10; // mutable — the default final int limit = 100; // opt OUT of mutation int counter = 0; counter += total; System.out.println("counter = " + counter); total = 11; // perfectly legal System.out.println("total is now " + total); System.out.println("limit is still " + limit); } }
Because the default runs the other way, final is used far less consistently than mut is — a great deal of Java code never writes it at all. The deeper difference is that final only freezes the binding: a final List<String> can still have elements added to it, whereas a Rust binding without mut also denies you &mut to what it holds. Java has no keyword for deep immutability.
Type inference and its limits
Java 10 added var, and it looks like let — but it works only where Rust's inference is also local. Everything Rust infers across a signature, Java makes you write down.
fn largest(values: &[i32]) -> i32 { let mut best = values[0]; for value in values { if *value > best { best = *value; } } best } fn main() { let numbers = vec![3, 17, 8]; // Vec<i32> inferred from the elements let winner = largest(&numbers); // return type inferred from the signature println!("{winner}"); }
class Main { // A method signature must be fully written out: both the parameter // type and the return type. "var" is not allowed in either position. static int largest(int[] values) { var best = values[0]; // var IS allowed for a local for (var value : values) { if (value > best) { best = value; } } return best; } public static void main(String[] args) { var numbers = new int[] { 3, 17, 8 }; var winner = largest(numbers); System.out.println(winner); } }
Rust infers a local's type from how it is later used, so let mut best; followed by an assignment is fine. Java's var is strictly initializer-driven: it needs a value on the same line, cannot appear on a field, a parameter or a return type, and cannot be null. That makes it a readability tool rather than the inference engine let is.
Primitives and their object wrappers
Java splits the number types in two. int, long, double, boolean and char are primitives — not objects, with no methods and no null. Everything else is a reference to a heap object, and each primitive has a matching wrapper class for the places only an object will do.
fn main() { let count: i32 = 7; // i32 IS the type. There is no second, heap-allocated version of it, // and putting one in a Vec does not change its representation. let counts: Vec<i32> = vec![count, 8, 9]; println!("{:?}", counts); // Heap allocation is something you ask for, by name. let boxed: Box<i32> = Box::new(count); println!("boxed = {boxed}"); }
import java.util.List; class Main { public static void main(String[] args) { int count = 7; // a primitive: not an object at all // A collection cannot hold a primitive, so "int" is silently // boxed into "Integer" — a heap object wrapping one number. List<Integer> counts = List.of(count, 8, 9); System.out.println(counts); Integer boxed = count; // autoboxing, no syntax required int unboxed = boxed; // auto-unboxing, likewise System.out.println(boxed + " " + unboxed); System.out.println("Integer is an object: " + boxed.getClass().getName()); } }
Rust has one i32 and you say Box when you want it on the heap. Java has int and Integer, and the conversion between them is invisible — which is why an innocent List<Integer> allocates an object per element. The consequences run through the whole page: generics cannot take a primitive, Integer can be null where int cannot, and == on two Integers compares references.
Shadowing
Shadowing is a small everyday convenience in Rust — the same name carried through a conversion, each let introducing a fresh binding. Java has no equivalent, so the conversion has to be spelled out with a new name at each step.
fn main() { let reading = "42"; println!("as text: {reading}"); // A NEW binding with the same name, and a different type. let reading: i32 = reading.parse().unwrap(); println!("as number: {reading}"); let reading = reading * 2; println!("doubled: {reading}"); }
class Main { public static void main(String[] args) { String readingText = "42"; System.out.println("as text: " + readingText); // "int readingText = ..." would not compile: a local variable name // cannot be declared twice in the same scope, at any type. Each // stage of the conversion needs its own name. int reading = Integer.parseInt(readingText); System.out.println("as number: " + reading); reading = reading * 2; // reassignment, not a new binding System.out.println("doubled: " + reading); } }
A Java local cannot be redeclared in the same scope, and it cannot be redeclared in a nested scope either while the outer one is still visible. The only shadowing Java does have is between a field and a local or parameter of the same name, which is why constructors are full of this.name = name; — and that is a source of bugs rather than a tool.
Constants and statics
Java has one spelling, static final, where Rust has two — and because there are no free items, even a top-level constant must be a field of some class.
const MAXIMUM_RETRIES: u32 = 3; // inlined at each use site static GREETING: &str = "hello"; // one address, 'static lifetime struct Circle; impl Circle { const SIDES: u32 = 0; // associated with the type } fn main() { println!("{MAXIMUM_RETRIES} {GREETING} {}", Circle::SIDES); }
class Circle { static final int SIDES = 0; // associated with the type } class Main { static final int MAXIMUM_RETRIES = 3; static final String GREETING = "hello"; public static void main(String[] args) { System.out.println(MAXIMUM_RETRIES + " " + GREETING + " " + Circle.SIDES); } }
Rust's const is substituted into each use site and static names a single memory location; the distinction matters because a static mut is unsound to touch without unsafe. Java's static final is one slot per class, initialized when the class loads, and a mutable static field is ordinary code that any thread may write — the compiler will not stop you. Screaming snake case is the convention in both languages.
Numbers & Overflow
Integer overflow
This is the safety default that flips hardest. Rust treats overflow as a bug and panics in debug builds; Java treats it as arithmetic and wraps, always, everywhere.
fn main() { let biggest = i32::MAX; // A debug build PANICS on overflow; a release build wraps. Because // that is ambiguous, the standard library makes you say which you meant. println!("wrapping: {}", biggest.wrapping_add(1)); println!("checked: {:?}", biggest.checked_add(1)); println!("saturating:{}", biggest.saturating_add(1)); let (value, overflowed) = biggest.overflowing_add(1); println!("overflowing: {value} (overflowed = {overflowed})"); }
class Main { public static void main(String[] args) { int biggest = Integer.MAX_VALUE; // Silent two's-complement wraparound. No panic, no warning, // in every build. This is the specified behavior. System.out.println("wrapping: " + (biggest + 1)); // Opting IN to detection, one operation at a time: try { System.out.println(Math.addExact(biggest, 1)); } catch (ArithmeticException overflow) { System.out.println("checked: " + overflow.getMessage()); } System.out.println("saturating: " + ((long) biggest + 1L)); } }
Rust makes you choose a policy — wrapping_, checked_, saturating_ or overflowing_ — precisely because silent wraparound is a decision rather than a default. Java's decision was made for you in 1995 and cannot change. Math.addExact and its siblings are the opt-in, and there is no saturating family at all: widening to long first is the usual workaround.
There are no unsigned types
Java has no u8, no u32, no usize — every integer type is signed. Unsigned values are represented by the same bits in a signed type, and interpreted by calling the right static method.
fn main() { let byte_value: u8 = 200; let big: u32 = 4_000_000_000; println!("u8: {byte_value}"); println!("u32: {big}"); // The type says non-negative, so the compiler enforces it. let difference = byte_value.checked_sub(255); println!("200 - 255 = {difference:?}"); }
class Main { public static void main(String[] args) { // Every Java integer type is SIGNED. A byte holds -128..127, so // the bit pattern for 200 reads back as a negative number. byte byteValue = (byte) 200; System.out.println("byte as signed: " + byteValue); System.out.println("byte as unsigned: " + Byte.toUnsignedInt(byteValue)); // The bits of 4_000_000_000 do not fit a positive int. int big = (int) 4_000_000_000L; System.out.println("int as signed: " + big); System.out.println("int as unsigned: " + Integer.toUnsignedString(big)); System.out.println("unsigned compare: " + Integer.compareUnsigned(big, 1)); } }
The consequence for a Rust programmer is that "cannot be negative" stops being a fact the type system knows. A length is an int, a byte read from a stream is a signed byte, and forgetting Byte.toUnsignedInt on binary data is one of Java's classic bugs. Integer.divideUnsigned, remainderUnsigned, compareUnsigned and toUnsignedString are the whole toolkit.
Numeric conversion
Rust asks for as on every numeric conversion, in both directions. Java splits them: widening happens by itself, narrowing needs a cast.
fn main() { let small: i32 = 300; // EVERY conversion is explicit, widening included. let widened: i64 = small as i64; let narrowed: u8 = small as u8; // truncates: 300 & 0xFF == 44 let as_float: f64 = small as f64; println!("{widened} {narrowed} {as_float}"); // The lossless, checked alternative: let attempt: Result<u8, _> = u8::try_from(small); println!("try_from: {:?}", attempt.is_err()); }
class Main { public static void main(String[] args) { int small = 300; // WIDENING is implicit — no cast, no ceremony. long widened = small; double asFloat = small; // NARROWING requires a cast, and truncates silently. byte narrowed = (byte) small; // 300 & 0xFF == 44 System.out.println(widened + " " + narrowed + " " + asFloat); // The checked alternative is a library method that throws. try { System.out.println(Math.toIntExact(5_000_000_000L)); } catch (ArithmeticException tooBig) { System.out.println("toIntExact: " + tooBig.getMessage()); } } }
The half Java does implicitly is the safe half, so this is less dangerous than it looks — but int to float and long to double are also implicit and both lose precision, which is the one place the rule leaks. Rust's TryFrom returns a Result; Java's nearest equivalent is a scattering of ...Exact methods that throw, and there is nothing at all for "narrow this int to a byte if it fits".
Beyond 64 bits
30 factorial needs 108 bits. Rust reaches for u128, a primitive type like any other; Java has nothing wider than long, so it reaches for a class.
fn main() { // Rust has a 128-bit integer built into the language. let mut factorial: u128 = 1; for multiplier in 1..=30u128 { factorial *= multiplier; } println!("30! = {factorial}"); println!("u128::MAX = {}", u128::MAX); }
import java.math.BigInteger; class Main { public static void main(String[] args) { // Java's widest primitive is 64-bit long, so this needs a class, // and every operation becomes a method call. BigInteger factorial = BigInteger.ONE; for (int multiplier = 1; multiplier <= 30; multiplier++) { factorial = factorial.multiply(BigInteger.valueOf(multiplier)); } System.out.println("30! = " + factorial); System.out.println("long max = " + Long.MAX_VALUE); System.out.println("unbounded = " + factorial.bitLength() + " bits"); } }
BigInteger is arbitrary precision rather than merely wider, so it never overflows — but there is no operator overloading in Java, so arithmetic turns into add, multiply and mod chains, and every intermediate result is a new heap object. This is the same trade Rust makes when reaching past u128, except Rust's ceiling is twice as high before you get there.
Strings
One String type, not two
Rust's split between an owned String and a borrowed &str exists because ownership has to be visible in the type. With a collector, it does not — Java has exactly one string type and every string is a reference to a heap object.
fn shout(text: &str) -> String { text.to_uppercase() } fn main() { let borrowed: &str = "ada"; // a view, no allocation let owned: String = String::from("lovelace"); // A &String coerces to &str, so one function accepts both. println!("{}", shout(borrowed)); println!("{}", shout(&owned)); let joined = format!("{borrowed} {owned}"); println!("{joined}"); }
class Main { static String shout(String text) { return text.toUpperCase(); } public static void main(String[] args) { String literal = "ada"; // still a String object String constructed = new StringBuilder("lovelace").toString(); System.out.println(shout(literal)); System.out.println(shout(constructed)); String joined = literal + " " + constructed; System.out.println(joined); } }
The &str versus String decision that shapes every Rust API signature simply does not arise: a Java method takes String and that is the end of it. What you lose is the guarantee a &str gave you — that no copy was made. Java string literals are interned into a shared pool, so identical literals really are one object, but any string built at run time is a fresh allocation.
UTF-8 scalars vs UTF-16 code units
Java predates UTF-8's victory, so a String is a sequence of UTF-16 code units and char is 16 bits. Anything outside the Basic Multilingual Plane — emoji, most historic scripts — is stored as a surrogate pair and counts as two.
fn main() { let text = "héllo 🦀"; println!("bytes (UTF-8): {}", text.len()); println!("chars (scalars):{}", text.chars().count()); // A char IS a Unicode scalar value, so the crab is one of them. let last = text.chars().last().unwrap(); println!("last char: {last}"); println!("its code point: {}", last as u32); }
import java.nio.charset.StandardCharsets; class Main { public static void main(String[] args) { String text = "héllo 🦀"; System.out.println("bytes (UTF-8): " + text.getBytes(StandardCharsets.UTF_8).length); System.out.println("length(): " + text.length()); // UTF-16 UNITS System.out.println("codePointCount():" + text.codePointCount(0, text.length())); // charAt gives a 16-bit code unit, so the crab is HALF a char. char lastUnit = text.charAt(text.length() - 1); System.out.println("charAt is a surrogate: " + Character.isSurrogate(lastUnit)); int lastCodePoint = text.codePointAt(text.offsetByCodePoints(0, text.codePointCount(0, text.length()) - 1)); System.out.println("last code point: " + lastCodePoint); System.out.println("as text: " + new String(Character.toChars(lastCodePoint))); } }
This is the single most misleading similarity between the two languages. Rust's char is a Unicode scalar value and always the whole character; Java's char is half of one whenever the code point is above U+FFFF. length() is therefore neither a byte count nor a character count, and code that iterates with charAt silently corrupts emoji. Use codePoints() when you mean characters — it is the closest thing Java has to chars().
Building a string in a loop
Both languages need a growable buffer for this, and for the same reason — repeated concatenation would reallocate each time. Rust grows the String in place; Java cannot, because a String is immutable, so it grows a separate StringBuilder.
fn main() { let words = ["never", "gonna", "give"]; let mut sentence = String::new(); for (index, word) in words.iter().enumerate() { if index > 0 { sentence.push(' '); } sentence.push_str(word); } println!("{sentence}"); // The one-liner, and the one you should reach for: println!("{}", words.join(" ")); }
import java.util.List; class Main { public static void main(String[] args) { List<String> words = List.of("never", "gonna", "give"); StringBuilder sentence = new StringBuilder(); for (int index = 0; index < words.size(); index++) { if (index > 0) { sentence.append(' '); } sentence.append(words.get(index)); } System.out.println(sentence.toString()); // The one-liner, and the one you should reach for: System.out.println(String.join(" ", words)); } }
Java strings are immutable in a way Rust's are not: String has no mutating methods at all, so text += word in a loop allocates a whole new string each pass. StringBuilder is the mutable counterpart, and calling toString() at the end copies once. Rust's String is already the growable buffer, which is why push_str exists on it directly.
Multi-line and raw strings
Rust's r"..." turns escaping off; Java's text block, delimited by three quotes, turns indentation stripping on. They solve different halves of the same annoyance and neither language has both.
fn main() { // A raw string: no escape processing at all. let pattern = r"C:\Users\ada"; println!("{pattern}"); // An ordinary literal spanning lines keeps every leading space // exactly as written — there is no indentation stripping. let message = "Dear reader, this line is indented by four spaces."; println!("{message}"); }
class Main { public static void main(String[] args) { // Java has no raw strings — a backslash is always an escape. String pattern = "C:\\Users\\ada"; System.out.println(pattern); // A text block strips the COMMON leading indentation, measured // against the closing delimiter's own indentation. String message = """ Dear reader, this line is indented by four spaces."""; System.out.println(message); } }
The stripping rule is worth learning before it surprises you: Java removes the longest common whitespace prefix of all lines including the closing delimiter line, so moving the closing """ changes the string's contents. Rust does no stripping at all, which is why a multi-line literal in indented code carries the indentation with it. Escapes still apply inside a Java text block, so a Windows path still needs doubled backslashes.
Slicing a string
Both languages index a string by storage unit rather than by character — bytes in Rust, UTF-16 code units in Java. What differs is what happens when the index lands in the middle of a character.
fn main() { let text = "héllo"; // Byte indices. &text[0..2] would panic: 'é' occupies bytes 1 and 2. let head = &text[0..3]; println!("head = {head}"); // The safe way to take characters rather than bytes: let first_two: String = text.chars().take(2).collect(); println!("first two chars = {first_two}"); println!("valid boundary at 2? {}", text.is_char_boundary(2)); }
class Main { public static void main(String[] args) { String text = "héllo"; // UTF-16 indices. 'é' is one code unit here, so 0..2 is fine — // but the same call on an emoji would split a surrogate pair and // return half a character, with no exception to warn you. String head = text.substring(0, 2); System.out.println("head = " + head); String firstTwo = text.codePoints() .limit(2) .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) .toString(); System.out.println("first two chars = " + firstTwo); System.out.println("length in units = " + text.length()); } }
Rust panics on a non-boundary index: &text[0..2] here would abort with a clear message about a character boundary. Java's substring will happily hand back a lone surrogate, which prints as a replacement character and compares equal to nothing you expect. That silence is the trap — an out-of-range index throws StringIndexOutOfBoundsException, but a mid-character one does not.
Splitting, trimming and searching
Almost everything here has a same-named counterpart. The differences are in the return types: Rust hands back lazy iterators and borrowed slices, Java hands back eagerly built arrays and new objects.
fn main() { let record = " ada,lovelace,1815 "; let trimmed = record.trim(); let fields: Vec<&str> = trimmed.split(',').collect(); println!("{:?}", fields); println!("starts with ada? {}", trimmed.starts_with("ada")); println!("position of comma: {:?}", trimmed.find(',')); println!("upper: {}", trimmed.replace(',', " | ")); }
import java.util.Arrays; class Main { public static void main(String[] args) { String record = " ada,lovelace,1815 "; String trimmed = record.strip(); String[] fields = trimmed.split(","); System.out.println(Arrays.toString(fields)); System.out.println("starts with ada? " + trimmed.startsWith("ada")); System.out.println("position of comma: " + trimmed.indexOf(',')); System.out.println("upper: " + trimmed.replace(",", " | ")); } }
Two traps hide in the resemblance. Java's split takes a regular expression, not a literal — splitting on "." matches every character — while Rust's split takes a pattern that is literal unless you say otherwise. And indexOf returns -1 for "not found" where find returns Option<usize>, which is the null-versus-Option story in miniature. Prefer strip() over the older trim(): only strip knows the full Unicode definition of whitespace.
Memory, References & Equality
Moves vs aliases
This is the central adjustment. Assigning a non-primitive in Java copies a reference: the object is not moved, not cloned, and not borrowed — it is simply now reachable through two names, both of which can mutate it.
fn main() { let mut original = vec![1, 2, 3]; // A move: "original" is no longer usable after this line. let taken = original; // println!("{original:?}"); // error[E0382]: borrow of moved value original = taken.clone(); // give the name a value again let alias = &original; // a BORROW is what aliasing looks like println!("original = {original:?}"); println!("alias = {alias:?}"); println!("taken = {taken:?}"); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> original = new ArrayList<>(List.of(1, 2, 3)); // No move. Two names, ONE list. List<Integer> alias = original; alias.add(4); System.out.println("original = " + original); // sees the 4 System.out.println("alias = " + alias); System.out.println("same object? " + (original == alias)); // A genuine copy is something you ask for: List<Integer> copy = new ArrayList<>(original); copy.add(5); System.out.println("original after copy.add = " + original); } }
Everything the borrow checker was buying you goes away here. There is no move, so the old name stays valid; there is no exclusive &mut, so any number of names can write at once; and there is no compile error waiting when two parts of a program mutate the same list. What you get back is that a cyclic or shared data structure needs no Rc, no RefCell and no lifetime annotation — you just hold the reference.
Returning a reference
Lifetimes exist to prove a reference outlives what it points at. A tracing collector proves the same thing at run time by never freeing anything still reachable — so the annotation has nothing left to say and Java does not have one.
// The lifetime says: the result borrows from "haystack", not "needle". fn first_matching<'a>(haystack: &'a [String], needle: &str) -> Option<&'a String> { haystack.iter().find(|candidate| candidate.contains(needle)) } fn main() { let names = vec![String::from("lovelace"), String::from("hopper")]; match first_matching(&names, "hop") { Some(found) => println!("found {found}"), None => println!("no match"), } }
import java.util.List; class Main { // Nothing to annotate. The collector keeps the object alive for as // long as any reference to it exists, so "how long is this valid" // is not a question the signature has to answer. static String firstMatching(List<String> haystack, String needle) { for (String candidate : haystack) { if (candidate.contains(needle)) { return candidate; } } return null; } public static void main(String[] args) { List<String> names = List.of("lovelace", "hopper"); String found = firstMatching(names, "hop"); System.out.println(found != null ? "found " + found : "no match"); } }
You can delete the whole of lifetime elision from your working memory here; there is no 'a, no 'static, no dangling reference and no "does not live long enough". The bill arrives in two other forms. Objects are freed at a time nobody controls, so Drop-style cleanup is not available; and the Option<&String> that made "no match" a distinct case has become null, which the compiler does not force you to check.
Equality and hashing
In Java == always compares references for objects — never contents. Value equality is a method, equals, and the collections call it for you.
use std::collections::HashSet; #[derive(Debug, PartialEq, Eq, Hash)] struct Point { x: i32, y: i32, } fn main() { let first = Point { x: 1, y: 2 }; let second = Point { x: 1, y: 2 }; // == IS value equality, and derive wrote it for you. println!("equal? {}", first == second); let mut seen = HashSet::new(); seen.insert(first); println!("already seen? {}", seen.contains(&second)); }
import java.util.HashSet; import java.util.Set; // A record generates equals and hashCode from its components — the // nearest thing Java has to #[derive(PartialEq, Eq, Hash)]. record Point(int x, int y) {} class Main { public static void main(String[] args) { Point first = new Point(1, 2); Point second = new Point(1, 2); System.out.println("== (reference): " + (first == second)); System.out.println("equals (value): " + first.equals(second)); Set<Point> seen = new HashSet<>(); seen.add(first); System.out.println("already seen? " + seen.contains(second)); } }
Writing first == second where you meant equals is the mistake every Rust programmer makes in their first week, and it is worst on strings: two identical literals are interned to one object so == appears to work, until one of them is built at run time and it silently stops. The other half of the contract is that hashCode must agree with equals — override one without the other and your object gets lost inside a HashSet. Rust's derive makes them consistent by construction; record is Java's version of that guarantee.
Copy, Clone and shallow copies
Rust makes you choose: Copy for a bitwise duplicate the compiler inserts silently, Clone for an explicit and possibly deep one. Java has neither trait, and nothing is ever duplicated implicitly.
#[derive(Debug, Clone, Copy)] struct Position { x: i32, y: i32 } #[derive(Debug, Clone)] struct Path { steps: Vec<Position> } fn main() { let start = Position { x: 0, y: 0 }; let also_start = start; // Copy: a bitwise duplicate println!("{start:?} {also_start:?}"); let route = Path { steps: vec![start] }; let mut duplicate = route.clone(); // deep: the Vec is cloned too duplicate.steps.push(Position { x: 1, y: 1 }); println!("route steps: {}", route.steps.len()); println!("duplicate steps: {}", duplicate.steps.len()); }
import java.util.ArrayList; import java.util.List; record Position(int x, int y) {} class Main { public static void main(String[] args) { Position start = new Position(0, 0); Position alsoStart = start; // a reference copy, not a duplicate System.out.println(start + " " + alsoStart); List<Position> route = new ArrayList<>(List.of(start)); // A "copy" constructor copies the LIST but not its elements — // shallow, always. There is no deep-clone in the language. List<Position> duplicate = new ArrayList<>(route); duplicate.add(new Position(1, 1)); System.out.println("route steps: " + route.size()); System.out.println("duplicate steps: " + duplicate.size()); } }
Every Java assignment behaves like Copy in that it is cheap and implicit, and like nothing at all in that no data is duplicated. The deep clone Rust's #[derive(Clone)] generates has no Java equivalent — Object.clone() is shallow, awkward and widely considered a design mistake, so real code writes a copy constructor by hand. The practical answer is to make types immutable, which is what record encourages, so that sharing a reference is as safe as copying.
Mutating while iterating
The classic borrow-checker rejection — iterating a collection while modifying it — is a compile error in Rust and a run-time exception in Java, if you are lucky enough to hit the check.
fn main() { let mut names = vec![String::from("ada"), String::from("grace")]; // The compiler REFUSES this: the loop holds an immutable borrow of // "names" while push() wants a mutable one. // // for name in &names { if name == "ada" { names.push(...); } } // error[E0502]: cannot borrow "names" as mutable ... // So you collect the work first, then apply it. let additions: Vec<String> = names .iter() .filter(|name| name.starts_with('a')) .map(|name| format!("{name}-junior")) .collect(); names.extend(additions); println!("{names:?}"); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<String> names = new ArrayList<>(List.of("ada", "grace")); // This COMPILES. It fails at run time, on a good day. try { for (String name : names) { if (name.startsWith("a")) { names.add(name + "-junior"); } } } catch (java.util.ConcurrentModificationException detected) { System.out.println("caught at run time: " + detected.getClass().getSimpleName()); } // Start again: the exception fired only AFTER one addition had // already landed, so the list is not what it was. names = new ArrayList<>(List.of("ada", "grace")); List<String> additions = names.stream() .filter(name -> name.startsWith("a")) .map(name -> name + "-junior") .toList(); names.addAll(additions); System.out.println(names); } }
Java's collections keep a modification counter and the iterator compares it on each step, which is what raises ConcurrentModificationException. The name is misleading: no second thread is involved. It is also explicitly best-effort — the docs say so — so the same bug can instead produce a silently wrong result, and it does not fire at all for a modification made through a different thread. This is exactly the class of bug &mut exclusivity was designed to make unrepresentable.
There is no Drop
Nothing runs when a Java object becomes unreachable. The collector may free it at any later time, or never — finalizers were deprecated for exactly this reason and removed in Java 18.
struct Connection { name: String } impl Drop for Connection { fn drop(&mut self) { println!("closing {}", self.name); } } fn main() { { let _database = Connection { name: String::from("database") }; println!("working..."); } // drop runs HERE, deterministically, at the closing brace println!("scope has ended"); }
class Connection implements AutoCloseable { private final String name; Connection(String name) { this.name = name; } @Override public void close() { System.out.println("closing " + name); } } class Main { public static void main(String[] args) { // try-with-resources calls close() at the closing brace. It is // the only deterministic cleanup Java has, and you must opt in // at every USE site, not once at the type. try (Connection database = new Connection("database")) { System.out.println("working..."); } System.out.println("scope has ended"); } }
The difference that matters is where the obligation sits. impl Drop is written once on the type and every value of that type is cleaned up, no matter how it is used; AutoCloseable only runs if each caller remembers try-with-resources, and a resource stored in a field is nobody's responsibility. That is why "leaked file handle" is a live category of Java bug and not a Rust one — and why RAII, which Rust inherited from C++, has no real counterpart here.
Collections
Vec and List
Java's collections are an interface hierarchy: List is the contract, ArrayList and LinkedList are implementations, and idiomatic code declares the interface so the implementation can change.
fn main() { let mut queue: Vec<String> = Vec::new(); queue.push(String::from("first")); queue.push(String::from("second")); println!("length {}", queue.len()); println!("index 0 = {}", queue[0]); println!("index 9 = {:?}", queue.get(9)); // None, not a panic let popped = queue.pop(); println!("popped {popped:?}, {} left", queue.len()); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { // The variable is typed by the INTERFACE, the object by the class. List<String> queue = new ArrayList<>(); queue.add("first"); queue.add("second"); System.out.println("length " + queue.size()); System.out.println("index 0 = " + queue.get(0)); try { queue.get(9); } catch (IndexOutOfBoundsException outOfRange) { System.out.println("index 9 = " + outOfRange.getMessage()); } String popped = queue.removeLast(); System.out.println("popped " + popped + ", " + queue.size() + " left"); } }
Rust has one growable array type and free functions on slices; Java has a family of them behind a shared interface, which is why the declaration and the constructor name different types. The other difference is the missing-element policy: get returns Option in Rust and throws in Java, so there is no "checked index" method to reach for — you check size() first or you catch.
Fixed arrays, and a hole in the type system
Java arrays are covariant — a String[] may be passed where an Object[] is expected. That rule is unsound, so every array write carries a hidden run-time type check.
fn main() { // A fixed-size array: the length is part of the type. let names: [&str; 3] = ["ada", "grace", "katherine"]; println!("{} elements", names.len()); // There is no subtyping between concrete types, so there is no // supertype array to widen into and nothing to check at run time. let widened: &[&str] = &names; // a slice, still exactly [&str] println!("first is still {}", widened[0]); }
class Main { public static void main(String[] args) { String[] names = { "ada", "grace", "katherine" }; System.out.println(names.length + " elements"); // Java arrays are COVARIANT: String[] is usable as Object[]. // That is unsound, so the check was deferred to run time. Object[] widened = names; try { widened[0] = Integer.valueOf(42); } catch (ArrayStoreException rejected) { System.out.println("rejected at run time: " + rejected.getMessage()); } System.out.println("first is still " + names[0]); } }
This is a genuine hole, kept for compatibility with Java 1.0, which had no generics and needed some way to write a method over any array. Rust has no subtyping between concrete types, so the question never arises; Java's own generics later got it right — List<String> is not a List<Object> — which is why arrays and generics behave differently and mix badly. Note the array's length is names.length, a field with no parentheses, unlike every collection's size().
Updating a map in place
Both languages solved the same problem — one lookup instead of two for "insert if missing, then update" — and arrived at different shapes. Rust exposes a reusable Entry value; Java bakes each combination into its own method.
use std::collections::HashMap; fn main() { let words = ["ada", "grace", "ada", "katherine", "ada"]; let mut counts: HashMap<&str, i32> = HashMap::new(); for word in words { *counts.entry(word).or_insert(0) += 1; } let mut pairs: Vec<_> = counts.into_iter().collect(); pairs.sort(); println!("{pairs:?}"); }
import java.util.Map; import java.util.TreeMap; class Main { public static void main(String[] args) { String[] words = { "ada", "grace", "ada", "katherine", "ada" }; Map<String, Integer> counts = new TreeMap<>(); // sorted by key for (String word : words) { counts.merge(word, 1, Integer::sum); } System.out.println(counts); // computeIfAbsent is the other half of the same idea. counts.computeIfAbsent("hopper", key -> 0); System.out.println("after computeIfAbsent: " + counts); } }
Rust's entry hands you a handle you can branch on and finish however you like, so or_insert, or_insert_with and and_modify are just methods on it. Java has no such handle: merge, compute, computeIfAbsent, computeIfPresent and putIfAbsent are five separate entry points covering the common cases. A TreeMap is used here only to make the printed order deterministic — HashMap iteration order is unspecified in both languages.
Immutable collections
Immutability lives in different places. In Rust it is a property of the binding, checked by the compiler. In Java it is a property of the object, checked when you call the method.
fn main() { let fixed = vec![1, 2, 3]; // No "mut", so there is no way to call push at all: // fixed.push(4); // error[E0596]: cannot borrow "fixed" as mutable println!("{fixed:?} — read-only because the binding says so"); let mut growable = fixed.clone(); growable.push(4); println!("{growable:?}"); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> fixed = List.of(1, 2, 3); // This COMPILES. List.of returns a List, and List has add(). try { fixed.add(4); } catch (UnsupportedOperationException refused) { System.out.println("refused at run time: UnsupportedOperationException"); } System.out.println(fixed + " — read-only because the object says so"); List<Integer> growable = new ArrayList<>(fixed); growable.add(4); System.out.println(growable); } }
Because List.of returns the same List interface as ArrayList, a method receiving a List cannot tell whether it is allowed to modify it — the type carries no such information, and the answer arrives as an exception at run time. There is no read-only list type to declare, which is the price of retrofitting immutability onto an interface designed in 1998. Rust puts the same fact in the signature: &Vec<T> and &mut Vec<T> are different types.
Sorting with a key
Rust's sort_by_key leans on tuples already being ordered, so a compound key is just a tuple. Java has no tuple, so the tie-break is built by chaining comparators instead.
#[derive(Debug)] struct Person { name: String, age: u32 } fn main() { let mut people = vec![ Person { name: String::from("grace"), age: 45 }, Person { name: String::from("ada"), age: 36 }, Person { name: String::from("katherine"), age: 36 }, ]; // Sort by age, then by name, without writing a comparator. people.sort_by_key(|person| (person.age, person.name.clone())); for person in &people { println!("{} {}", person.age, person.name); } }
import java.util.ArrayList; import java.util.Comparator; import java.util.List; record Person(String name, int age) {} class Main { public static void main(String[] args) { List<Person> people = new ArrayList<>(List.of( new Person("grace", 45), new Person("ada", 36), new Person("katherine", 36) )); // Comparators compose, which is how the tie-break is expressed. people.sort(Comparator.comparingInt(Person::age) .thenComparing(Person::name)); for (Person person : people) { System.out.println(person.age() + " " + person.name()); } } }
Both sorts are stable. The interesting divergence is the key type: Rust's closure has to hand back an owned value, which is why the name is cloned here — sort_by with a two-argument comparator is the allocation-free alternative. Java's comparingInt avoids boxing the age, and thenComparing reads in the order you would say it aloud. Note List.of produces an immutable list, so it is copied into an ArrayList before sorting.
First, last and reversed
Java 21 added sequenced collections, which finally gave List, Deque and LinkedHashSet one shared vocabulary for "the end you care about" — the methods a Rust programmer expects to already be there.
use std::collections::VecDeque; fn main() { let letters = vec!["a", "b", "c"]; println!("first {:?}, last {:?}", letters.first(), letters.last()); let reversed: Vec<_> = letters.iter().rev().collect(); println!("{reversed:?}"); let mut deque: VecDeque<&str> = VecDeque::from(letters); deque.push_front("start"); deque.push_back("end"); println!("{deque:?}"); }
import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.SequencedSet; class Main { public static void main(String[] args) { List<String> letters = new ArrayList<>(List.of("a", "b", "c")); System.out.println("first " + letters.getFirst() + ", last " + letters.getLast()); System.out.println(letters.reversed()); letters.addFirst("start"); letters.addLast("end"); System.out.println(letters); // Sequenced collections gave a Set an order-aware API too. SequencedSet<String> ordered = new LinkedHashSet<>(letters); System.out.println("set first = " + ordered.getFirst()); } }
Before this, getting the last element of a List meant list.get(list.size() - 1), and the same operation had a different name on every collection type. reversed() is a view, not a copy, so it is closer to iter().rev() than to into_iter().rev().collect() — writing through it writes through to the original list.
Control Flow
if as an expression
Rust is expression-oriented: if, match, loop and a block all produce values. Java draws a hard line between statements and expressions, and if is on the statement side.
fn main() { let temperature = 31; // if is an expression, so it can be the value of a let. let advice = if temperature > 30 { "stay inside" } else if temperature > 15 { "perfect" } else { "bring a coat" }; println!("{advice}"); }
class Main { public static void main(String[] args) { int temperature = 31; // "if" is a STATEMENT, so it cannot produce a value. Either // declare first and assign in the branches... String advice; if (temperature > 30) { advice = "stay inside"; } else if (temperature > 15) { advice = "perfect"; } else { advice = "bring a coat"; } System.out.println(advice); // ...or use the conditional operator, which IS an expression // but only nests awkwardly. String shorter = temperature > 30 ? "stay inside" : temperature > 15 ? "perfect" : "bring a coat"; System.out.println(shorter); } }
The workaround Java reaches for first — declare the variable, then assign in each branch — is still checked for definite assignment, so forgetting a branch is a compile error rather than a null. The conditional operator is the real expression form, and it is the reason so much Java code stacks ? : chains where Rust would use if. Java's switch expression, further down this page, is where the language did finally adopt the idea.
Ranges and loops
Java has no range syntax and no range type. The counted loop is written out in full, and IntStream is where anything range-shaped ends up.
fn main() { for index in 0..3 { println!("exclusive {index}"); } for index in (0..=6).step_by(3) { println!("stepped {index}"); } for (position, letter) in ["a", "b"].iter().enumerate() { println!("{position} -> {letter}"); } }
import java.util.List; import java.util.stream.IntStream; class Main { public static void main(String[] args) { // The C-style loop is still the everyday one. for (int index = 0; index < 3; index++) { System.out.println("exclusive " + index); } // The nearest thing to a range object lives in the Stream API. IntStream.iterate(0, index -> index <= 6, index -> index + 3) .forEach(index -> System.out.println("stepped " + index)); // There is no enumerate: the index is tracked by hand. List<String> letters = List.of("a", "b"); for (int position = 0; position < letters.size(); position++) { System.out.println(position + " -> " + letters.get(position)); } } }
The enhanced for loop — for (String letter : letters) — is the direct counterpart of for letter in &letters, and it is what you should reach for whenever the index is not needed. When it is needed there is no enumerate, so counting by hand is idiomatic rather than a smell. IntStream.range covers 0..n and rangeClosed covers 0..=n, but neither is usable as a value the way a Rust range is.
Breaking out of nested loops
Labeled break is one of the few places the two languages agree almost exactly — Java has had it since 1995, and it works on continue as well.
fn main() { let grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; let target = 5; 'search: for (row_index, row) in grid.iter().enumerate() { for (column_index, value) in row.iter().enumerate() { if *value == target { println!("found {target} at {row_index},{column_index}"); break 'search; } } } // A loop can also RETURN a value through break. let mut attempt = 0; let doubled = loop { attempt += 1; if attempt == 4 { break attempt * 2; } }; println!("doubled = {doubled}"); }
class Main { public static void main(String[] args) { int[][] grid = { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } }; int target = 5; search: for (int rowIndex = 0; rowIndex < grid.length; rowIndex++) { for (int columnIndex = 0; columnIndex < grid[rowIndex].length; columnIndex++) { if (grid[rowIndex][columnIndex] == target) { System.out.println("found " + target + " at " + rowIndex + "," + columnIndex); break search; } } } // A Java loop cannot produce a value, so the result is a variable. int attempt = 0; int doubled; while (true) { attempt++; if (attempt == 4) { doubled = attempt * 2; break; } } System.out.println("doubled = " + doubled); } }
The syntax barely differs: Rust marks the label with a leading apostrophe and repeats it after break, Java writes a bare identifier and a colon. Where they part company is break value — a Rust loop is an expression that can hand a value back, and no Java loop can. The label itself is also more general in Java, since it may be attached to any statement, including a bare block you can jump out of.
match and switch as expressions
The switch expression, arrow form and all, is Java's closest approach to match. Multiple labels on one arm are comma-separated in both languages.
fn describe(day: &str) -> &str { match day { "saturday" | "sunday" => "weekend", "monday" => "the hard one", _ => "a weekday", } } fn main() { for day in ["saturday", "monday", "thursday"] { println!("{day}: {}", describe(day)); } }
class Main { static String describe(String day) { // The arrow form: no fall-through, no break, and it is an // EXPRESSION, so it returns a value. return switch (day) { case "saturday", "sunday" -> "weekend"; case "monday" -> "the hard one"; default -> "a weekday"; }; } public static void main(String[] args) { for (String day : new String[] { "saturday", "monday", "thursday" }) { System.out.println(day + ": " + describe(day)); } } }
The arrow form fixed the two oldest complaints about switch: cases no longer fall through, so the break that everyone forgot is gone, and the whole construct produces a value. It is only a partial match — the labels here are constants, not patterns, and it does not destructure. The next section is where that gap closes. When an arm needs several statements, wrap it in a block and end with yield, which is the counterpart of a Rust block's trailing expression.
Consuming until empty
Rust's while let works because pop_front returns Option: the loop ends when the pattern stops matching. Java's pollFirst returns null when empty, and there is no syntax that binds and tests together.
use std::collections::VecDeque; fn main() { let mut pending: VecDeque<i32> = VecDeque::from(vec![3, 1, 4]); // while let binds and tests in one step. while let Some(job) = pending.pop_front() { println!("handling {job}, {} left", pending.len()); } println!("done"); }
import java.util.ArrayDeque; import java.util.Deque; import java.util.List; class Main { public static void main(String[] args) { Deque<Integer> pending = new ArrayDeque<>(List.of(3, 1, 4)); // No binding-and-testing in one step: test emptiness, then take. while (!pending.isEmpty()) { int job = pending.pollFirst(); System.out.println("handling " + job + ", " + pending.size() + " left"); } System.out.println("done"); } }
You could write Integer job; while ((job = pending.pollFirst()) != null), and older Java code does, but assignment-inside-a-condition is exactly the pattern while let exists to replace — and here it would also unbox a possibly-null Integer. Checking isEmpty() first is the readable version and the one to write. Note ArrayDeque, not Stack: the older class is synchronized and effectively deprecated.
Methods, Lambdas & Closures
There are no free functions
Rust separates the data (struct) from the behavior (impl), and lets a function stand alone when it belongs to neither. Java has one construct for all three, so every function is a member of some class.
// A free function, at the top level of the module. fn celsius_to_fahrenheit(celsius: f64) -> f64 { celsius * 9.0 / 5.0 + 32.0 } struct Thermometer { reading: f64 } impl Thermometer { // An associated function: no self, called on the type. fn freezing() -> Thermometer { Thermometer { reading: 0.0 } } // A method: takes self. fn fahrenheit(&self) -> f64 { celsius_to_fahrenheit(self.reading) } } fn main() { println!("{}", celsius_to_fahrenheit(100.0)); println!("{}", Thermometer::freezing().fahrenheit()); }
class Thermometer { private final double reading; private Thermometer(double reading) { this.reading = reading; } // A static method is the only "function not attached to an object". static double celsiusToFahrenheit(double celsius) { return celsius * 9.0 / 5.0 + 32.0; } static Thermometer freezing() { return new Thermometer(0.0); } // An instance method: the receiver is implicit, and is named "this". double fahrenheit() { return celsiusToFahrenheit(reading); } } class Main { public static void main(String[] args) { System.out.println(Thermometer.celsiusToFahrenheit(100.0)); System.out.println(Thermometer.freezing().fahrenheit()); } }
The mapping is clean once you see it: a Rust associated function without self is a Java static method, a method taking &self is an instance method, and a free function has to be adopted by whichever class it fits. The receiver is spelled self and declared in Rust, and is the implicit this in Java — which is why you can write reading instead of this.reading inside a method. There is no self versus &self versus &mut self distinction, because there is no ownership to express.
Overloading, which Rust does not have
Java lets several methods share a name as long as their parameter lists differ. Rust has no overloading at all — a name in a scope refers to exactly one function.
// One name, one signature. Variants get their own names. fn area_of_square(side: f64) -> f64 { side * side } fn area_of_rectangle(width: f64, height: f64) -> f64 { width * height } fn main() { println!("{}", area_of_square(3.0)); println!("{}", area_of_rectangle(3.0, 4.0)); }
class Main { // Same name, different parameter lists. The compiler picks one // by the STATIC types at the call site. static double area(double side) { return side * side; } static double area(double width, double height) { return width * height; } static String area(String label) { return "cannot measure " + label; } public static void main(String[] args) { System.out.println(area(3.0)); System.out.println(area(3.0, 4.0)); System.out.println(area("a mood")); } }
Overload resolution happens at compile time against the declared types, not the run-time ones, which is why it interacts badly with autoboxing and null: area(null) here would be ambiguous if a second single-object overload existed. Rust's answer to the same need is either distinct names, as above, or a generic function with a trait bound — which is a genuinely different mechanism, since it resolves one implementation per concrete type rather than choosing between hand-written candidates. Neither language has default parameter values.
Closures and what they capture
Java lambdas capture by value only, and only variables that are never reassigned — "effectively final". There is no borrowing closure, no move, and no FnMut.
fn main() { let mut running_total = 0; // A closure borrowing mutably — the compiler works out how much // access it needs, and forbids using running_total meanwhile. let mut add = |amount: i32| running_total += amount; add(5); add(7); println!("total = {running_total}"); // "move" takes ownership instead. let greeting = String::from("hello"); let shout = move || println!("{}!", greeting.to_uppercase()); shout(); }
import java.util.concurrent.atomic.AtomicInteger; import java.util.function.IntConsumer; class Main { public static void main(String[] args) { // A lambda can only capture an EFFECTIVELY FINAL local, so a // running total has to live in something mutable of its own. AtomicInteger runningTotal = new AtomicInteger(); IntConsumer add = amount -> runningTotal.addAndGet(amount); add.accept(5); add.accept(7); System.out.println("total = " + runningTotal.get()); // Capture is always by value, and the value is a reference. String greeting = "hello"; Runnable shout = () -> System.out.println(greeting.toUpperCase() + "!"); shout.run(); } }
Rust names three capture modes because ownership makes them observably different; Java has one, so mutating captured state means capturing a mutable object instead of a mutable variable — an AtomicInteger, a one-element array, or a field. The restriction exists because a Java lambda may outlive the stack frame it was created in, and copying the value is the only way to keep that sound without a borrow checker. A lambda body also cannot return from the enclosing method the way a Rust closure cannot.
Fn traits and functional interfaces
Java has no function type. A lambda is an instance of a functional interface — any interface with a single abstract method — so Function, Predicate, Supplier, Consumer and Runnable are the vocabulary you write instead of Fn(A) -> B.
// A bound saying "anything callable with an i32 returning i32". fn apply_twice<F: Fn(i32) -> i32>(function: F, value: i32) -> i32 { function(function(value)) } fn main() { println!("{}", apply_twice(|number| number + 3, 10)); // Stored in a variable, the type is written out: let describe: Box<dyn Fn(i32) -> String> = Box::new(|number| format!("value is {number}")); println!("{}", describe(7)); }
import java.util.function.Function; import java.util.function.IntUnaryOperator; class Main { // There is no function TYPE. The parameter is an interface with // exactly one abstract method, and a lambda implements it. static int applyTwice(IntUnaryOperator function, int value) { return function.applyAsInt(function.applyAsInt(value)); } public static void main(String[] args) { System.out.println(applyTwice(number -> number + 3, 10)); Function<Integer, String> describe = number -> "value is " + number; System.out.println(describe.apply(7)); } }
The practical friction is that the method name changes with the interface: apply, test, get, accept, run — where Rust just calls the value. Because generics cannot take primitives, the java.util.function package also carries a primitive-specialized copy of nearly everything (IntUnaryOperator, ToIntFunction, IntPredicate), which is the boxing tax showing up in the API surface. What Java gains is that any interface you write with one abstract method accepts a lambda, with no Fn equivalent to implement.
Method references
The syntax is Type::method in both languages, and it means slightly different things: Java's form can also bind an instance method so that the incoming argument becomes the receiver.
fn main() { let words = vec!["ada", "grace"]; // A path to a function can be passed where a closure is expected. let shouted: Vec<String> = words.iter().map(|word| word.to_uppercase()).collect(); println!("{shouted:?}"); // Naming the function directly works when the shapes line up. let lengths: Vec<usize> = words.iter().copied().map(str::len).collect(); println!("{lengths:?}"); // A constructor is a function too. let owned: Vec<String> = words.iter().copied().map(String::from).collect(); println!("{owned:?}"); }
import java.util.List; class Main { public static void main(String[] args) { List<String> words = List.of("ada", "grace"); // An INSTANCE method on the stream element: the element becomes // the receiver. List<String> shouted = words.stream().map(String::toUpperCase).toList(); System.out.println(shouted); List<Integer> lengths = words.stream().map(String::length).toList(); System.out.println(lengths); // A constructor reference. List<StringBuilder> builders = words.stream().map(StringBuilder::new).toList(); System.out.println(builders); } }
Java recognizes four shapes — Type::staticMethod, instance::method, Type::instanceMethod (the argument is the receiver) and Type::new. Rust has only the first and last, because a method reference that turns an argument into a receiver is just str::len taking &self as its first parameter, which is already how Rust methods work. The compiler figures out which shape applies from the target functional interface, so the same text can mean different things in different contexts.
Variable-length argument lists
Java's ... lets a caller pass any number of arguments, and the method receives them as an array. Rust has nothing equivalent outside macros, so callers build a slice or a Vec themselves.
// Rust has no varargs. The idiom is a slice. fn total(values: &[i32]) -> i32 { values.iter().sum() } fn main() { println!("{}", total(&[1, 2, 3])); println!("{}", total(&[])); let collected = vec![10, 20]; println!("{}", total(&collected)); }
class Main { // The last parameter may be variadic. Inside the method it is // an ordinary array. static int total(int... values) { int sum = 0; for (int value : values) { sum += value; } return sum; } public static void main(String[] args) { System.out.println(total(1, 2, 3)); System.out.println(total()); int[] collected = { 10, 20 }; System.out.println(total(collected)); // an array works too } }
This is a small ergonomic win for Java, and it is what makes String.format, List.of and printf read the way they do. The costs are that only the final parameter may be variadic, that the array is allocated on every call, and that varargs plus overloading plus autoboxing produce genuinely confusing resolution — total(null) is ambiguous in a way no Rust call can be. Rust's answer, a slice parameter, costs one pair of brackets at each call site and nothing at run time.
Classes & Records
From struct plus impl to class
A constructor is a real language feature here, not a convention: it shares the class's name, has no return type, and is invoked through new. Rust's new is just a function someone chose to call that.
struct Rectangle { width: f64, height: f64, } impl Rectangle { fn new(width: f64, height: f64) -> Rectangle { Rectangle { width, height } } fn area(&self) -> f64 { self.width * self.height } fn scale(&mut self, factor: f64) { self.width *= factor; self.height *= factor; } } fn main() { let mut shape = Rectangle::new(3.0, 4.0); println!("area {}", shape.area()); shape.scale(2.0); println!("scaled area {}", shape.area()); }
class Rectangle { private double width; // fields are private by convention private double height; // A constructor: same name as the class, no return type. Rectangle(double width, double height) { this.width = width; this.height = height; } double area() { return width * height; } void scale(double factor) { this.width *= factor; this.height *= factor; } } class Main { public static void main(String[] args) { Rectangle shape = new Rectangle(3.0, 4.0); System.out.println("area " + shape.area()); shape.scale(2.0); System.out.println("scaled area " + shape.area()); } }
The mechanical differences are small — data and methods live in one block rather than two, and self becomes an implicit this. The real one is that scale needs no &mut: any method may mutate the object, so nothing in a Java signature tells a caller whether it will. Rust's three receiver forms are load-bearing type information; Java simply does not carry it. Fields are made private and reached through methods because Java, unlike Rust, cannot later replace a public field with a computed one without breaking callers.
Records: the derive you already know
A record is Java's answer to a struct with #[derive(Debug, Clone, PartialEq, Eq, Hash)]. It is a class whose components are final and whose boilerplate the compiler writes.
#[derive(Debug, Clone, PartialEq)] struct Measurement { label: String, value: f64, } fn main() { let reading = Measurement { label: String::from("depth"), value: 12.5 }; let same = reading.clone(); println!("{reading:?}"); println!("field access: {} {}", reading.label, reading.value); println!("equal? {}", reading == same); }
// One line generates: a constructor, an accessor per component, // equals, hashCode and toString. The fields are final. record Measurement(String label, double value) {} class Main { public static void main(String[] args) { Measurement reading = new Measurement("depth", 12.5); Measurement same = new Measurement("depth", 12.5); System.out.println(reading); System.out.println("field access: " + reading.label() + " " + reading.value()); System.out.println("equal? " + reading.equals(same)); } }
This is the construct that will feel most familiar, and it is worth defaulting to. The differences are worth knowing: the accessors are label() rather than a bare field, so a record's shape is methods rather than data; there is no derived Clone, because an immutable object never needs one; and there is no Default, PartialOrd or Copy to derive. A record is also implicitly final and cannot extend anything, which is what makes it safe to compare by value.
Inheritance, which Rust does not have
Implementation inheritance is the one major idea Java has that Rust deliberately omits. A subclass inherits fields and method bodies, may override any non-final method, and can reach the overridden version through super.
// Rust composes: the shared part is a field, not a parent. struct Animal { name: String, } impl Animal { fn describe(&self) -> String { format!("{} is an animal", self.name) } } struct Dog { animal: Animal, } impl Dog { fn describe(&self) -> String { format!("{}, and a dog", self.animal.describe()) } } fn main() { let rex = Dog { animal: Animal { name: String::from("Rex") } }; println!("{}", rex.describe()); }
class Animal { protected final String name; // visible to subclasses Animal(String name) { this.name = name; } String describe() { return name + " is an animal"; } } class Dog extends Animal { Dog(String name) { super(name); // the parent constructor runs first } @Override String describe() { return super.describe() + ", and a dog"; } } class Main { public static void main(String[] args) { Animal rex = new Dog("Rex"); // declared Animal, actually a Dog System.out.println(rex.describe()); } }
Note the last line: the variable is declared Animal but holds a Dog, and the Dog version runs. Every Java instance method is virtual by default, which is dynamic dispatch you did not ask for — the opposite of Rust, where you write dyn to get it. The trade is real: a subclass can depend on and break its parent's internals in ways a trait implementation never can, which is why "prefer composition over inheritance" is standing Java advice and why final on a class is worth writing.
Validating before construction
A Java constructor must produce an object of its class — it cannot return an alternative, and it cannot return nothing. Refusal is therefore a thrown exception rather than an Err.
struct Percentage { value: u8, } impl Percentage { // A constructor is just a function, so it can return a Result, // do work first, or refuse to build anything at all. fn new(value: u8) -> Result<Percentage, String> { if value > 100 { return Err(format!("{value} is more than 100")); } Ok(Percentage { value }) } } fn main() { println!("{:?}", Percentage::new(50).map(|percentage| percentage.value)); println!("{:?}", Percentage::new(150).map(|percentage| percentage.value)); }
class Percentage { protected final int value; Percentage(int value) { this.value = value; } } class StrictPercentage extends Percentage { StrictPercentage(int value) { // Java 25 finally allows statements BEFORE super(). Until then // this was a compile error, and validation had to be smuggled // into a static helper called from inside the super() arguments. if (value > 100) { throw new IllegalArgumentException(value + " is more than 100"); } super(value); } } class Main { public static void main(String[] args) { System.out.println(new StrictPercentage(50).value); try { new StrictPercentage(150); } catch (IllegalArgumentException rejected) { System.out.println("rejected: " + rejected.getMessage()); } } }
Rust has no constructors, so the "make one or explain why not" function is unremarkable: it returns Result like anything else. Java's equivalent has to throw, and the caller has no signature-level warning that it might. That the check can sit above super() at all is new in Java 25 — before it, a constructor had to call its parent first, so an argument could only be validated inside a static helper passed as an argument to super(). The usual escape hatch remains a static factory method, which can return a subtype, a cached instance, or an Optional.
Display, Debug and toString
Rust splits rendering in two: Debug for programmers, derivable, and Display for users, always hand-written. Java has one method, toString, and every object already has a version of it.
use std::fmt; #[derive(Debug)] struct Temperature { celsius: f64 } // Display is a separate, hand-written trait: the user-facing form. impl fmt::Display for Temperature { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "{:.1}°C", self.celsius) } } fn main() { let reading = Temperature { celsius: 21.456 }; println!("display: {reading}"); println!("debug: {reading:?}"); }
class Temperature { private final double celsius; Temperature(double celsius) { this.celsius = celsius; } // There is ONE conversion, inherited from Object and overridden here. @Override public String toString() { return "%.1f°C".formatted(celsius); } } class Main { public static void main(String[] args) { Temperature reading = new Temperature(21.456); System.out.println("display: " + reading); System.out.println("implicit: " + reading.toString()); } }
Because toString is inherited from Object, forgetting to override it produces something like Temperature@6d06d69c rather than a compile error — the counterpart of a missing Display is silence instead of a diagnostic. Anything concatenated with a string calls it implicitly, including inside a logger or a debugger. A record generates a reasonable toString for free, which makes it the closest thing to #[derive(Debug)].
Visibility
Java has four levels and the default is the one with no keyword: public, protected, package-private (write nothing), and private. The unit of privacy is the package, and it maps to a directory.
mod billing { pub struct Invoice { pub reference: String, amount: u32, // private to this module } impl Invoice { pub fn new(reference: &str, amount: u32) -> Invoice { Invoice { reference: reference.to_string(), amount } } pub(crate) fn amount(&self) -> u32 { self.amount } } } fn main() { let invoice = billing::Invoice::new("A-1", 250); println!("{} {}", invoice.reference, invoice.amount()); }
class Invoice { public final String reference; // visible everywhere private final int amount; // visible in this class only int internalCode; // NO keyword: visible in the package Invoice(String reference, int amount) { this.reference = reference; this.amount = amount; this.internalCode = 0; } public int amount() { return amount; } } class Main { public static void main(String[] args) { Invoice invoice = new Invoice("A-1", 250); System.out.println(invoice.reference + " " + invoice.amount()); System.out.println("package-visible field: " + invoice.internalCode); } }
Rust's default is private-to-the-module and every level is spelled out — pub, pub(crate), pub(super). Java's omitted keyword meaning "package-visible" catches people out, because it looks like no decision was made. protected has no Rust analog at all: it means visible to subclasses and to the package, which only makes sense once inheritance exists. Above all of this sits the module system, whose exports directive is the nearest thing to pub(crate) at library scale.
Traits vs Interfaces
Traits and interfaces
The shapes line up almost exactly. A Java interface declares method signatures, a class declares implements, and a variable of the interface type holds any implementer.
trait Greeter { fn name(&self) -> String; } struct Dog; struct Robot; impl Greeter for Dog { fn name(&self) -> String { String::from("Rex") } } impl Greeter for Robot { fn name(&self) -> String { String::from("Unit 7") } } fn announce(greeter: &dyn Greeter) { println!("hello, {}", greeter.name()); } fn main() { announce(&Dog); announce(&Robot); }
interface Greeter { String name(); // implicitly public and abstract } class Dog implements Greeter { @Override public String name() { return "Rex"; } } class Robot implements Greeter { @Override public String name() { return "Unit 7"; } } class Main { static void announce(Greeter greeter) { System.out.println("hello, " + greeter.name()); } public static void main(String[] args) { announce(new Dog()); announce(new Robot()); } }
Two differences are structural rather than cosmetic. The conformance is declared on the class, in its header, rather than in a separate block — so a class must know at the moment it is written which interfaces it satisfies. And there is no dyn to write, because an interface-typed variable is always dynamically dispatched; Java has no static-dispatch form of this at all. A class may implement any number of interfaces, so multiple traits carry over unchanged.
Default method bodies
Both languages let an interface supply a method body that implementers inherit and may override. Java calls it a default method and added it in Java 8, for a specific reason.
trait Distance { fn meters(&self) -> f64; // A default body, overridable by any implementer. fn kilometers(&self) -> f64 { self.meters() / 1000.0 } } struct Marathon; impl Distance for Marathon { fn meters(&self) -> f64 { 42_195.0 } } fn main() { println!("{:.3} km", Marathon.kilometers()); }
interface Distance { double meters(); // Added in Java 8 so interfaces could grow without breaking // every existing implementer. default double kilometers() { return meters() / 1000.0; } } class Marathon implements Distance { @Override public double meters() { return 42_195.0; } } class Main { public static void main(String[] args) { System.out.printf("%.3f km%n", new Marathon().kilometers()); } }
Traits had default methods from the start; Java retrofitted them so that Iterable could gain forEach without breaking every class that already implemented it. The one thing Java cannot do is hold state — an interface has no instance fields, so a default method may only call other interface methods. Rust's restriction is the same in practice. Where an inherited default collides between two interfaces, Java makes the class disambiguate explicitly, which is its answer to the diamond problem.
You cannot implement an interface for a type you do not own
A Java class lists its interfaces in its own source. If you did not write the class, you cannot add one — and String is final as well, so you cannot even subclass it.
trait Shout { fn shout(&self) -> String; } // Implementing OUR trait for the standard library's String. // Legal because the trait is local — this is the coherence rule. impl Shout for String { fn shout(&self) -> String { format!("{}!!!", self.to_uppercase()) } } fn main() { let name = String::from("ada"); println!("{}", name.shout()); }
interface Shout { String shout(); } // String is final and already written; it can never implement Shout. // The workaround is an adapter that WRAPS it. record ShoutingText(String text) implements Shout { @Override public String shout() { return text.toUpperCase() + "!!!"; } } class Main { public static void main(String[] args) { String name = "ada"; System.out.println(new ShoutingText(name).shout()); } }
This is the single largest expressive gap in the direction you are travelling. Rust's coherence rule lets you implement your own trait for any type, which is why extension-style APIs are everywhere; Java's answer is a wrapper class, and the wrapper is a different type, so it does not flow through code expecting the original. The nearest thing to a blanket impl is a static helper method taking the type as a parameter, which is how Collections, Objects and Arrays came to exist at all.
Ord and Comparable
Both languages express "this type has a natural order" as an interface with one comparison method. The difference is that Rust can derive it from the field order and Java cannot.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct Version { major: u32, minor: u32, } fn main() { let mut releases = vec![ Version { major: 2, minor: 1 }, Version { major: 1, minor: 9 }, Version { major: 2, minor: 0 }, ]; releases.sort(); println!("{releases:?}"); println!("newest: {:?}", releases.iter().max()); }
import java.util.ArrayList; import java.util.Collections; import java.util.List; record Version(int major, int minor) implements Comparable<Version> { // No derive: the comparison is written, and must be consistent // with equals or sorted collections misbehave. @Override public int compareTo(Version other) { int byMajor = Integer.compare(major, other.major); return byMajor != 0 ? byMajor : Integer.compare(minor, other.minor); } } class Main { public static void main(String[] args) { List<Version> releases = new ArrayList<>(List.of( new Version(2, 1), new Version(1, 9), new Version(2, 0) )); Collections.sort(releases); System.out.println(releases); System.out.println("newest: " + Collections.max(releases)); } }
compareTo returns a negative number, zero or a positive one — the C convention — where cmp returns an Ordering enum with three named variants. Rust also splits PartialOrd from Ord so that floating-point types, which have no total order because of NaN, can say so in the type system; Java has one interface and Double implements it by declaring NaN to be larger than everything, which is a decision rather than a fact. Both languages warn that an order inconsistent with equality breaks sorted collections; only Rust's derive makes them consistent automatically.
Static and dynamic dispatch
Rust asks you to choose: a generic parameter is monomorphized into one specialized copy per type, while dyn Trait is a single copy with a vtable. Java offers no such choice at the language level.
trait Renderer { fn render(&self) -> String; } struct Terminal; struct Html; impl Renderer for Terminal { fn render(&self) -> String { String::from("plain text") } } impl Renderer for Html { fn render(&self) -> String { String::from("<p>markup</p>") } } // STATIC: one copy compiled per concrete type, calls inlined. fn draw_static<R: Renderer>(renderer: &R) { println!("static: {}", renderer.render()); } // DYNAMIC: one copy, a vtable lookup per call. fn draw_dynamic(renderer: &dyn Renderer) { println!("dynamic: {}", renderer.render()); } fn main() { draw_static(&Terminal); draw_dynamic(&Html); }
interface Renderer { String render(); } class Terminal implements Renderer { @Override public String render() { return "plain text"; } } class Html implements Renderer { @Override public String render() { return "<p>markup</p>"; } } class Main { // Both of these dispatch dynamically. A type parameter is erased, // so the generic version compiles to the same virtual call. static <R extends Renderer> void drawGeneric(R renderer) { System.out.println("generic: " + renderer.render()); } static void drawInterface(Renderer renderer) { System.out.println("interface: " + renderer.render()); } public static void main(String[] args) { drawGeneric(new Terminal()); drawInterface(new Html()); } }
Every Java call through an interface or an overridable method is a virtual call, and a generic type parameter changes nothing — it is erased to its bound, so drawGeneric and drawInterface compile to the same bytecode. What rescues the performance is the JIT: after seeing one implementation at a call site it will speculatively inline and de-virtualize, achieving at run time what Rust settles at compile time. The trade is a warm-up cost and a de-optimization whenever a second implementation shows up.
Sealed Types & Pattern Matching
The two things called enum
The word is shared and the meaning is not. A Rust enum is a sum type whose variants carry data; a Java enum is a closed set of named singleton instances of one class.
#[derive(Debug)] enum Status { Idle, Running { progress: u8 }, // variants CARRY data Failed(String), } fn main() { let states = vec![ Status::Idle, Status::Running { progress: 40 }, Status::Failed(String::from("disk full")), ]; for state in &states { println!("{state:?}"); } }
enum Status { IDLE, RUNNING, FAILED; } class Main { public static void main(String[] args) { // A Java enum is a fixed set of SINGLETON constants. No variant // can carry per-value data, so nothing here can hold a progress // percentage or a failure message. for (Status state : Status.values()) { System.out.println(state + " (ordinal " + state.ordinal() + ")"); } System.out.println("parsed: " + Status.valueOf("FAILED")); } }
Java's enum is genuinely useful — the constants are objects, so they may have fields, methods and even per-constant bodies, and they come with values(), ordinal() and valueOf for free. But every constant of a given enum has the same type, so the moment two states need different data, an enum is the wrong tool. The next row is where Java's real sum type lives.
Sealed interfaces are the real sum type
A sealed interface plus a record per variant is Java's sum type, and it is a close match. permits names every implementer, so the compiler knows the list is complete.
enum Shape { Circle { radius: f64 }, Rectangle { width: f64, height: f64 }, } fn area(shape: &Shape) -> f64 { match shape { Shape::Circle { radius } => std::f64::consts::PI * radius * radius, Shape::Rectangle { width, height } => width * height, } } fn main() { let shapes = vec![ Shape::Circle { radius: 1.0 }, Shape::Rectangle { width: 2.0, height: 3.0 }, ]; for shape in &shapes { println!("{:.3}", area(shape)); } }
import java.util.List; // "permits" closes the hierarchy: no other type may implement Shape, // which is what lets the compiler check a switch for exhaustiveness. sealed interface Shape permits Circle, Rectangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} class Main { static double area(Shape shape) { return switch (shape) { case Circle circle -> Math.PI * circle.radius() * circle.radius(); case Rectangle rectangle -> rectangle.width() * rectangle.height(); // No default arm — and none is needed. }; } public static void main(String[] args) { List<Shape> shapes = List.of(new Circle(1.0), new Rectangle(2.0, 3.0)); for (Shape shape : shapes) { System.out.printf("%.3f%n", area(shape)); } } }
Everything you rely on carries over: the switch is checked for exhaustiveness, so adding a third shape without handling it is a compile error, and the default arm that would have hidden that mistake is unnecessary. What differs is the shape of the declaration — one type per variant rather than one type with variants — so Circle is a real type you can name in a signature, which Rust's Shape::Circle is not. Sealed types arrived in Java 17 and pattern-matching switch in Java 21; before that, this was written with a visitor or a chain of instanceof.
Destructuring in a pattern
Record patterns let a case label take the record apart and bind its components, so the shape of the pattern mirrors the shape of the constructor — the same trick a Rust struct pattern plays.
enum Message { Move { x: i32, y: i32 }, Write(String), Quit, } fn describe(message: &Message) -> String { match message { Message::Move { x, y } => format!("move to {x},{y}"), Message::Write(text) => format!("write {:?}", text), Message::Quit => String::from("quit"), } } fn main() { println!("{}", describe(&Message::Move { x: 3, y: 4 })); println!("{}", describe(&Message::Write(String::from("hi")))); println!("{}", describe(&Message::Quit)); }
sealed interface Message permits Move, Write, Quit {} record Move(int x, int y) implements Message {} record Write(String text) implements Message {} record Quit() implements Message {} class Main { static String describe(Message message) { return switch (message) { // The components are bound by position, exactly like a // Rust tuple-struct pattern. case Move(int x, int y) -> "move to " + x + "," + y; case Write(String text) -> "write \"" + text + "\""; case Quit ignored -> "quit"; }; } public static void main(String[] args) { System.out.println(describe(new Move(3, 4))); System.out.println(describe(new Write("hi"))); System.out.println(describe(new Quit())); } }
The parallel is close enough to be worth leaning on, and the gaps are real but narrow. Java has no | for alternative patterns that bind variables, no .. for "and the rest", and no range patterns; a variable pattern binds by position rather than by field name, so you cannot write Move(y) and skip x. Nesting works — case Write(String text) could be case Outer(Write(String text)) — and that is where record patterns earn their place.
Guards
Java spells the guard when where Rust spells it if, and it attaches to a pattern the same way. Arms are still tried top to bottom, so ordering carries the same weight.
fn classify(value: i32) -> &'static str { match value { number if number < 0 => "negative", 0 => "zero", number if number % 2 == 0 => "positive even", _ => "positive odd", } } fn main() { for value in [-4, 0, 6, 7] { println!("{value}: {}", classify(value)); } }
class Main { static String classify(Integer value) { return switch (value) { case Integer number when number < 0 -> "negative"; case 0 -> "zero"; case Integer number when number % 2 == 0 -> "positive even"; default -> "positive odd"; }; } public static void main(String[] args) { for (Integer value : new Integer[] { -4, 0, 6, 7 }) { System.out.println(value + ": " + classify(value)); } } }
A guarded arm never counts towards exhaustiveness in either language — the compiler cannot prove the condition is always true — which is why a final unguarded arm is still required here. Rust's wildcard is _ and Java's is default, and Java also accepts a bare _ as an unnamed pattern variable when the binding is not used.
if let and instanceof binding
Pattern-matching instanceof, added in Java 16, replaced the test-then-cast dance with a single expression that binds the narrowed value.
enum Payload { Text(String), Number(i64), } fn main() { let payload = Payload::Text(String::from("hello")); // if let: match one pattern and bind, ignore the rest. if let Payload::Text(message) = &payload { println!("text of length {}", message.len()); } // let-else: bind or leave early. let Payload::Text(message) = &payload else { println!("not text"); return; }; println!("still have {message}"); }
class Main { public static void main(String[] args) { Object payload = "hello"; // The test and the binding are one expression, and "message" // is in scope only where the test succeeded. if (payload instanceof String message) { System.out.println("text of length " + message.length()); } // The negated form: the binding is in scope AFTER the if, // because the compiler knows the method already returned. if (!(payload instanceof String message)) { System.out.println("not text"); return; } System.out.println("still have " + message); } }
The negated form is Java's let-else, and it works through flow scoping: the compiler tracks where the pattern must have matched, so the binding is visible after an early return but not on the failing path. Rust's version is explicit in the syntax rather than inferred, and it enforces that the else branch diverges. Note the Java example types the variable as Objectinstanceof against a sealed hierarchy is the everyday use, but any reference type works.
Generics & Erasure
Generic functions and bounds
The declaration order is the surprise: Java writes the type parameters immediately before the return type, so static <T> T largest(...) reads back-to-front compared with fn largest<T>(...) -> T.
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T { let mut winner = items[0]; for item in items { if *item > winner { winner = *item; } } winner } fn main() { println!("{}", largest(&[3, 17, 8])); println!("{}", largest(&['a', 'z', 'm'])); println!("{}", largest(&[1.5, 0.5])); }
import java.util.List; class Main { // The type parameter goes BEFORE the return type, and the bound // is "extends" whether the bound is a class or an interface. static <T extends Comparable<T>> T largest(List<T> items) { T winner = items.get(0); for (T item : items) { if (item.compareTo(winner) > 0) { winner = item; } } return winner; } public static void main(String[] args) { System.out.println(largest(List.of(3, 17, 8))); System.out.println(largest(List.of('a', 'z', 'm'))); System.out.println(largest(List.of(1.5, 0.5))); } }
A bound is written extends in both cases, even when the bound is an interface, and several are joined with & rather than +. There is no where clause. The deeper difference is that the bound is the only thing the method body knows: item.compareTo is available because Comparable declares it, and nothing else is — which is exactly how a trait bound behaves. Note that > becomes compareTo(...) > 0, because Java has no operator overloading.
Erasure: the generics are gone at run time
Java generics were added in 2004 without changing the virtual machine, so the compiler checks the type arguments and then erases them. At run time List<String> and List<Integer> are the same class.
use std::any::type_name; fn describe<T>(_value: &T) -> &'static str { // Monomorphization: a separate copy of this function is compiled // for every T, and the type is known inside it. type_name::<T>() } fn main() { let numbers = vec![1, 2, 3]; let words = vec![String::from("a")]; println!("{}", describe(&numbers)); println!("{}", describe(&words)); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3)); List<String> words = new ArrayList<>(List.of("a", "b", "c")); // Both are the same class at run time. The type argument was // erased by the compiler after checking it. System.out.println(numbers.getClass().getName()); System.out.println(words.getClass().getName()); System.out.println("same class? " + (numbers.getClass() == words.getClass())); // Which is why an unchecked cast is only a warning — and the // mistake surfaces at the point of USE, not the point of error. List<Object> sneaky = (List<Object>) (List<?>) numbers; sneaky.add("not an integer"); System.out.println("a String is now in the List<Integer>: " + numbers); try { int broken = numbers.get(3); System.out.println(broken); } catch (ClassCastException late) { System.out.println("ClassCastException: " + late.getMessage()); } } }
This is the deepest structural difference in the whole language pair, and it costs you several things at once. There is no T::default() or T::new(), because T does not exist at run time; new T[10] is a compile error; value instanceof List<String> cannot be written; and two overloads differing only in type argument have the same erased signature and will not compile. The compensation is that there is no code bloat and no compile-time monomorphization cost — one ArrayList serves every element type.
A type parameter can never be a primitive
Erasure means a type argument must be a reference type, so List<int> is a compile error. Primitives are boxed into their wrapper classes to get into any generic container.
fn sum(values: &[i32]) -> i32 { // i32 all the way down: no boxing, no wrappers, one machine word // per element in the underlying array. values.iter().sum() } fn main() { let values: Vec<i32> = (1..=5).collect(); println!("sum: {}", sum(&values)); println!("bytes per element: {}", std::mem::size_of::<i32>()); }
import java.util.List; import java.util.stream.IntStream; class Main { public static void main(String[] args) { // List<int> does not compile. Every element is a heap Integer, // with a pointer in the array pointing at it. List<Integer> values = IntStream.rangeClosed(1, 5).boxed().toList(); int sum = 0; for (int value : values) { // unboxes on every iteration sum += value; } System.out.println("sum: " + sum); // The escape hatch: a primitive-specialized stream. System.out.println("sum via IntStream: " + IntStream.rangeClosed(1, 5).sum()); } }
The consequences are everywhere once you look. A List<Integer> of a million elements is a million heap objects plus a million pointers, where a Vec<i32> is four megabytes of contiguous memory; caching behavior differs accordingly. This is why IntStream, LongStream, DoubleStream, IntUnaryOperator and dozens of similar types exist — hand-written specializations for the three primitive types anybody cared about. Project Valhalla is the long-running effort to fix this properly, and it has not shipped.
Wildcards and variance
A List<Integer> is not a List<Number> — Java generics are invariant, like Rust's. Wildcards are the opt-in that relaxes it at the use site.
// Rust generics are invariant, so "a list of anything numeric" // is expressed with a bound rather than a variance annotation. fn total<T: Into<f64> + Copy>(values: &[T]) -> f64 { values.iter().map(|value| (*value).into()).sum::<f64>() } fn main() { println!("{}", total(&[1i32, 2, 3])); println!("{}", total(&[1.5f32, 2.5])); }
import java.util.List; class Main { // "? extends Number" — produce FROM this list. Accepts // List<Integer>, List<Double>, List<Number>. static double total(List<? extends Number> values) { double sum = 0; for (Number value : values) { sum += value.doubleValue(); } return sum; } // "? super Integer" — consume INTO this list. static void addFirstThree(List<? super Integer> destination) { destination.add(1); destination.add(2); destination.add(3); } public static void main(String[] args) { System.out.println(total(List.of(1, 2, 3))); System.out.println(total(List.of(1.5, 2.5))); List<Number> collected = new java.util.ArrayList<>(); addFirstThree(collected); System.out.println(collected); } }
The mnemonic is PECS: Producer extends, Consumer super. A List<? extends Number> can be read from but not added to, because nobody knows which subtype it really holds; a List<? super Integer> can be added to but reads back as Object. Rust has variance too, but it applies to lifetimes and to built-in types rather than being something you write, and the equivalent flexibility comes from trait bounds instead. Wildcards are the price of subtyping — with no subtyping there is nothing to vary.
A generic type of your own
A generic class declares its parameters once, in the class header, and every method may use them — there is no second impl<T> block to repeat them in.
#[derive(Debug)] 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> { self.items.pop() } } fn main() { let mut stack: Stack<&str> = Stack::new(); stack.push("first"); stack.push("second"); println!("{:?}", stack.pop()); println!("{:?}", stack); }
import java.util.ArrayList; import java.util.List; import java.util.Optional; class Stack<T> { private final List<T> items = new ArrayList<>(); void push(T item) { items.add(item); } Optional<T> pop() { return items.isEmpty() ? Optional.empty() : Optional.of(items.removeLast()); } @Override public String toString() { return "Stack" + items; } } class Main { public static void main(String[] args) { // The diamond <> infers the argument from the declaration. Stack<String> stack = new Stack<>(); stack.push("first"); stack.push("second"); System.out.println(stack.pop()); System.out.println(stack); } }
That single declaration is the ergonomic win; the loss is that a method cannot add a bound the class does not have, so impl<T: Display> Stack<T> — extra methods available only for some element types — has no equivalent. Every method exists for every T. The diamond <> on the right-hand side is inference in the one place Java bothers with it, and it is why new ArrayList<>() reads the way it does.
null vs Option
null is back
Every Java reference type includes null, and the compiler does not track it. A method returning String may return null, and its signature cannot say either way.
fn find_user(id: u32) -> Option<String> { if id == 1 { Some(String::from("ada")) } else { None } } fn main() { // The type FORCES the absent case to be handled. match find_user(2) { Some(name) => println!("found {name}"), None => println!("no such user"), } // Calling a method on the Option itself is a compile error; // you must open it first. let length = find_user(1).map(|name| name.len()); println!("{length:?}"); }
class Main { static String findUser(int id) { return id == 1 ? "ada" : null; } public static void main(String[] args) { // Nothing in the type says this can be absent. Nothing in the // compiler requires the check. It is a convention. String name = findUser(2); System.out.println(name != null ? "found " + name : "no such user"); // Forget the check and you get this, at run time: try { System.out.println(findUser(2).length()); } catch (NullPointerException missing) { System.out.println("NPE: " + missing.getMessage()); } } }
This is the safety property you will miss most. There is one consolation: since Java 14, the NullPointerException message names the exact expression that was null"Cannot invoke String.length() because the return value of findUser is null" — which turns the site's worst error message into one of its best. Note also that primitives cannot be null, so unboxing an Integer that happens to be null throws in a place with no visible method call at all.
Optional, and where it belongs
Optional is the shape you recognize, arriving twenty years late. It is a class in java.util, not a language feature, and the official guidance is to use it for return types and not for fields, parameters or collection elements.
fn find_user(id: u32) -> Option<String> { if id == 1 { Some(String::from("ada")) } else { None } } struct Session { // Option is at home ANYWHERE: fields, parameters, collections. current_user: Option<String>, } fn main() { let session = Session { current_user: find_user(1) }; println!("{}", session.current_user.unwrap_or_else(|| String::from("guest"))); println!("{:?}", find_user(9).map(|name| name.len())); }
import java.util.Optional; class Main { // Optional is a LIBRARY class added in Java 8, intended for return // types only. It is not serializable and it can itself be null. static Optional<String> findUser(int id) { return id == 1 ? Optional.of("ada") : Optional.empty(); } public static void main(String[] args) { System.out.println(findUser(1).orElseGet(() -> "guest")); System.out.println(findUser(9).map(String::length)); findUser(1).ifPresentOrElse( name -> System.out.println("found " + name), () -> System.out.println("no such user") ); } }
The reason for that narrow guidance is that Optional cannot replace null — it sits on top of it. An Optional reference can itself be null, so Optional<String> has three states where Option<String> has two, and every wrapper is a heap allocation. It also has no pattern matching: orElse, orElseGet, ifPresentOrElse and map are the whole vocabulary, and get() is the unwrap() you should not reach for.
Chaining through absence
Java has no null-safe navigation operator — no ?., no ??. Chaining through possibly-absent values means lifting the value into an Optional first.
#[derive(Debug)] struct Address { city: Option<String> } #[derive(Debug)] struct User { address: Option<Address> } fn main() { let with_city = User { address: Some(Address { city: Some(String::from("London")) }), }; let without = User { address: None }; for user in [&with_city, &without] { let city = user .address .as_ref() .and_then(|address| address.city.as_deref()) .unwrap_or("unknown"); println!("{city}"); } }
import java.util.Optional; record Address(String city) {} record User(Address address) {} class Main { public static void main(String[] args) { User withCity = new User(new Address("London")); User without = new User(null); for (User user : new User[] { withCity, without }) { // There is no ?. operator, so the chain is built by wrapping. String city = Optional.ofNullable(user.address()) .map(Address::city) .orElse("unknown"); System.out.println(city); } } }
Optional.ofNullable is the bridge from the null world to the Optional world, and map here does the work of and_then — because map on an empty Optional short-circuits, and the mapper returning null also yields empty. When the mapper itself returns an Optional, use flatMap, which is the direct counterpart of and_then. The alternative every codebase also contains is a stack of nested if statements, and it is not wrong — just longer.
Error Handling
Result and exceptions
Java's failures travel out of band. A method that fails does not return anything — it throws, and control jumps to the nearest enclosing catch for a matching type.
fn parse_port(text: &str) -> Result<u16, std::num::ParseIntError> { text.parse::<u16>() } fn main() { // Failure is a VALUE, returned normally, visible in the type. match parse_port("8080") { Ok(port) => println!("port {port}"), Err(problem) => println!("bad port: {problem}"), } match parse_port("not-a-port") { Ok(port) => println!("port {port}"), Err(problem) => println!("bad port: {problem}"), } }
class Main { static int parsePort(String text) { return Integer.parseInt(text); // throws on failure } public static void main(String[] args) { // Failure is a THROW, which unwinds until something catches it. try { System.out.println("port " + parsePort("8080")); } catch (NumberFormatException problem) { System.out.println("bad port: " + problem.getMessage()); } try { System.out.println("port " + parsePort("not-a-port")); } catch (NumberFormatException problem) { System.out.println("bad port: " + problem.getMessage()); } } }
The consequence for a Rust programmer is that the return type stops telling you whether a call can fail. Integer.parseInt returns int, full stop, and only the documentation says otherwise. In exchange the happy path is uncluttered: there is no Ok(...) to wrap, no ? to remember, and a chain of ten calls needs one try rather than ten propagations. Both mechanisms unwind — a Rust panic! unwinds the same way — but only Java's is the ordinary route for expected failures.
Checked exceptions, which Rust has no name for
Java splits exceptions in two. An unchecked one (RuntimeException and its subclasses) may be thrown anywhere. A checked one must be declared with throws, and every caller must either catch it or declare it too.
fn read_setting(raw: &str) -> Result<i32, String> { raw.parse::<i32>().map_err(|problem| problem.to_string()) } // Nothing in Rust forces a CALLER to handle a Result — but nothing // lets it ignore one silently either: the type must be dealt with. fn main() { let value = read_setting("12"); println!("{value:?}"); // A bare call would warn, because Result is #[must_use]; // "let _ =" is how you say you really do mean to discard it. let _ = read_setting("nope"); println!("done"); }
class Main { // "throws" is part of the SIGNATURE, and the compiler enforces it: // a caller must catch it or declare it in turn. static int readSetting(String raw) throws Exception { try { return Integer.parseInt(raw); } catch (NumberFormatException problem) { throw new Exception("bad setting: " + raw, problem); } } public static void main(String[] args) { try { System.out.println(readSetting("12")); System.out.println(readSetting("nope")); } catch (Exception refused) { System.out.println("caught: " + refused.getMessage()); System.out.println("cause: " + refused.getCause().getClass().getSimpleName()); } } }
Checked exceptions are the closest Java gets to putting failure in the type, and they are the one error-handling idea here with no Rust counterpart at all. They are also controversial: because throws propagates up through every intermediate signature, a deep call stack ends up either declaring the whole world or wrapping everything in RuntimeException to escape. They interact badly with lambdas as well — none of the functional interfaces in java.util.function declare throws, so a checked exception cannot escape a stream operation. The cause chain shown here is Java's answer to map_err: the original failure is retained rather than replaced.
Propagating a failure upward
Propagation is the default in Java: an uncaught exception leaves the method by itself. There is no ? because there is nothing to write — the absence of a catch is the propagation.
fn parse_pair(text: &str) -> Result<(i32, i32), std::num::ParseIntError> { let mut parts = text.split(','); // Each ? returns early with the Err if there is one. let left: i32 = parts.next().unwrap_or("").trim().parse()?; let right: i32 = parts.next().unwrap_or("").trim().parse()?; Ok((left, right)) } fn main() { println!("{:?}", parse_pair("3, 4")); println!("{:?}", parse_pair("3, x").is_err()); }
class Main { static int[] parsePair(String text) { String[] parts = text.split(","); // No operator needed: if either call throws, this method // stops and the exception continues outward on its own. int left = Integer.parseInt(parts[0].trim()); int right = Integer.parseInt(parts[1].trim()); return new int[] { left, right }; } public static void main(String[] args) { int[] pair = parsePair("3, 4"); System.out.println(pair[0] + " " + pair[1]); try { parsePair("3, x"); } catch (NumberFormatException propagated) { System.out.println("propagated: " + propagated.getMessage()); } } }
This is where exceptions genuinely read better, and it is worth conceding. What it costs is locality: every call in that method is a possible exit point and none of them is marked, so "where can this return from" is no longer answerable by reading the body. Rust's ? marks each one and, through the From conversion it performs, forces you to decide how a foreign error type becomes yours — a decision Java makes implicitly by letting the original type fly straight through.
An error type of your own
Where Rust reaches for an enum with a variant per failure mode, Java reaches for a class hierarchy — a base exception with a subclass per case, so that catch can select by type.
use std::fmt; #[derive(Debug)] enum ConfigError { Missing(String), OutOfRange { field: String, value: i32 }, } impl fmt::Display for ConfigError { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { match self { ConfigError::Missing(field) => write!(formatter, "{field} is missing"), ConfigError::OutOfRange { field, value } => { write!(formatter, "{field} = {value} is out of range") } } } } impl std::error::Error for ConfigError {} fn main() { let problems = vec![ ConfigError::Missing(String::from("port")), ConfigError::OutOfRange { field: String::from("retries"), value: 99 }, ]; for problem in &problems { println!("{problem}"); } }
// An error type is a CLASS, and the variants become subclasses. class ConfigError extends Exception { ConfigError(String message) { super(message); } } class MissingField extends ConfigError { MissingField(String field) { super(field + " is missing"); } } class OutOfRange extends ConfigError { OutOfRange(String field, int value) { super(field + " = " + value + " is out of range"); } } class Main { public static void main(String[] args) { ConfigError[] problems = { new MissingField("port"), new OutOfRange("retries", 99), }; for (ConfigError problem : problems) { System.out.println(problem.getMessage()); } } }
The shapes are dual: Rust matches on a value of one type, Java catches on one of several types. That gives Java something Rust lacks — a caller may catch MissingField specifically and let everything else pass — and costs it exhaustiveness, since no compiler checks that every subclass is handled. Making the base sealed and catching with a pattern switch recovers some of that. Note the message lives in the exception rather than in a Display implementation, because Throwable already carries one.
panic! and unchecked exceptions
Both languages separate "this input was bad" from "this program is wrong". Rust calls the second a panic; Java calls it an unchecked exception, and the difference is how hard the language makes it to ignore that distinction.
fn main() { let values = vec![1, 2, 3]; // A panic is for bugs: an index that should never be out of range. // Catching one is deliberately awkward. let result = std::panic::catch_unwind(|| values[10]); println!("panicked? {}", result.is_err()); // The everyday, non-panicking form: println!("{:?}", values.get(10)); }
import java.util.List; class Main { public static void main(String[] args) { List<Integer> values = List.of(1, 2, 3); // An unchecked exception is for bugs too — but catching one // uses exactly the same syntax as catching anything else. try { values.get(10); } catch (IndexOutOfBoundsException bug) { System.out.println("caught a bug: " + bug.getMessage()); } // There is no non-throwing form to prefer. System.out.println("size check first: " + (values.size() > 10)); } }
Rust puts a wall between the two: catch_unwind is unidiomatic, it does not work with panic = "abort", and the value it hands back is a Box<dyn Any> you cannot usefully inspect. Java uses one mechanism for both, so a stray catch (Exception e) swallows genuine bugs alongside expected failures — which is why catching broadly is a recognized code smell rather than a convenience. The other half of the story is that Java offers no checked-index alternative: where Rust has get returning Option, Java has only get that throws.
Iterators & Streams
Iterator and Stream
The pipeline reads almost identically. stream() starts one, filter and map are intermediate operations, and a terminal operation such as toList() or count() runs it.
fn main() { let names = vec!["ada", "grace", "katherine", "jean"]; let shouted: Vec<String> = names .iter() .filter(|name| name.len() > 3) .map(|name| name.to_uppercase()) .collect(); println!("{shouted:?}"); println!("count: {}", names.iter().filter(|name| name.len() > 3).count()); }
import java.util.List; class Main { public static void main(String[] args) { List<String> names = List.of("ada", "grace", "katherine", "jean"); List<String> shouted = names.stream() .filter(name -> name.length() > 3) .map(String::toUpperCase) .toList(); System.out.println(shouted); System.out.println("count: " + names.stream().filter(name -> name.length() > 3).count()); } }
Two rules differ and both bite. A Java stream is single-use: calling a second terminal operation on the same stream throws IllegalStateException, which is why names.stream() is written twice above where Rust would need a fresh iter() for the same reason. And Stream is not Iterable, so it cannot be used in a for loop — the two worlds are joined only by forEach and by collection.stream(). Rust's Iterator is the one abstraction for both jobs.
Both are lazy, and you can see it
Nothing in either pipeline runs until a terminal operation asks for a value, and then elements are pulled through one at a time rather than stage by stage. The printed trace shows it: mapping stops as soon as an answer is found.
fn main() { let numbers = vec![1, 2, 3, 4, 5]; let first_big = numbers .iter() .map(|number| { println!(" mapping {number}"); number * 10 }) .find(|scaled| *scaled > 20); println!("found {first_big:?}"); }
import java.util.List; import java.util.Optional; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3, 4, 5); Optional<Integer> firstBig = numbers.stream() .map(number -> { System.out.println(" mapping " + number); return number * 10; }) .filter(scaled -> scaled > 20) .findFirst(); System.out.println("found " + firstBig); } }
Java calls this short-circuiting, and it applies to findFirst, anyMatch, limit and friends exactly as it does to find and take. The visible difference is what "not found" looks like: an Optional that prints as Optional.empty. The invisible one is that a Java stream may also be run in parallel, which is why its operations are required to be stateless and non-interfering in a way Iterator adapters are not.
collect and Collectors
Both languages end a pipeline with collect, and then diverge completely. Rust picks the destination from the annotated type through the FromIterator trait; Java passes a Collector object that says what to build.
use std::collections::HashMap; fn main() { let words = vec!["ada", "amy", "grace", "gina", "jean"]; // The target collection is chosen by the TYPE. let by_initial: HashMap<char, Vec<&str>> = words.iter().fold(HashMap::new(), |mut grouped, word| { grouped.entry(word.chars().next().unwrap()).or_default().push(word); grouped }); let mut initials: Vec<_> = by_initial.keys().copied().collect(); initials.sort(); for initial in initials { println!("{initial}: {:?}", by_initial[&initial]); } }
import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.stream.Collectors; class Main { public static void main(String[] args) { List<String> words = List.of("ada", "amy", "grace", "gina", "jean"); // The target collection is chosen by the COLLECTOR argument. Map<Character, List<String>> byInitial = words.stream() .collect(Collectors.groupingBy(word -> word.charAt(0), TreeMap::new, Collectors.toList())); for (Map.Entry<Character, List<String>> group : byInitial.entrySet()) { System.out.println(group.getKey() + ": " + group.getValue()); } } }
Java's way is more verbose and considerably more capable out of the box: groupingBy, partitioningBy, toMap, joining, summingInt and counting compose into one another, and groupingBy in particular has no standard-library equivalent in Rust — the fold above is what you write instead. Rust's way needs no vocabulary at all for the common cases, since collect::<Vec<_>>() and collect::<HashMap<_, _>>() follow from the type alone.
fold and reduce
The everyday reductions line up: sum, max, min, count. The general form does not — Java's three-argument reduce needs a combiner, and joining strings is better served by a collector.
fn main() { let amounts = vec![120, 80, 45]; println!("sum: {}", amounts.iter().sum::<i32>()); println!("max: {:?}", amounts.iter().max()); // fold carries an accumulator of a DIFFERENT type. let summary = amounts .iter() .fold(String::new(), |mut text, amount| { if !text.is_empty() { text.push_str(" + "); } text.push_str(&amount.to_string()); text }); println!("{summary}"); }
import java.util.List; import java.util.stream.Collectors; class Main { public static void main(String[] args) { List<Integer> amounts = List.of(120, 80, 45); System.out.println("sum: " + amounts.stream().mapToInt(Integer::intValue).sum()); System.out.println("max: " + amounts.stream().max(Integer::compare)); // reduce over a different type needs a THIRD argument, the // combiner, because the stream may have been split in parallel. String summary = amounts.stream() .map(String::valueOf) .collect(Collectors.joining(" + ")); System.out.println(summary); } }
The combiner exists because a Java stream may split its work across threads and then merge the partial results, so the accumulation has to be associative and the merge has to be expressible. Rust's fold is strictly sequential and therefore needs no such thing. Note mapToInt before sum: a Stream<Integer> has no sum at all, because sums are defined only on the primitive streams — one more place where erasure shows through.
Chunking and windowing
Chunking is a slice method in Rust and, until recently, was missing from Java entirely. Gatherers, final in Java 24, are the extension point that finally made it expressible.
fn main() { let readings = vec![1, 2, 3, 4, 5, 6, 7]; // chunks: non-overlapping, on a slice. for chunk in readings.chunks(3) { println!("chunk {chunk:?}"); } // windows: overlapping, sliding by one. for window in readings.windows(3).take(2) { println!("window {window:?}"); } }
import java.util.List; import java.util.stream.Gatherers; import java.util.stream.Stream; class Main { public static void main(String[] args) { List<Integer> readings = List.of(1, 2, 3, 4, 5, 6, 7); // Gatherers arrived in Java 24 — custom intermediate operations, // which the Stream API had no way to express before. readings.stream() .gather(Gatherers.windowFixed(3)) .forEach(chunk -> System.out.println("chunk " + chunk)); Stream.of(1, 2, 3, 4, 5, 6, 7) .gather(Gatherers.windowSliding(3)) .limit(2) .forEach(window -> System.out.println("window " + window)); } }
A Gatherer is to an intermediate operation what a Collector is to a terminal one, and it is the thing the Stream API lacked for a decade: before it, anything not on the fixed list of built-in operations had to be written as a loop. windowFixed and windowSliding match chunks and windows; fold, scan and mapConcurrent round out the built-in set, and you can write your own. Rust's versions work on slices rather than iterators, which is why they can hand back borrowed views instead of new lists.
Infinite sequences
Both languages can describe an endless sequence and then cut it short. take is spelled limit, and the generator is Stream.iterate rather than successors.
fn main() { // An unbounded range, made finite by take. let squares: Vec<u64> = (1u64..).map(|number| number * number).take(5).collect(); println!("{squares:?}"); // successors builds a sequence from the previous value. let doubling: Vec<u64> = std::iter::successors(Some(1u64), |previous| Some(previous * 2)) .take(6) .collect(); println!("{doubling:?}"); }
import java.util.List; import java.util.stream.LongStream; import java.util.stream.Stream; class Main { public static void main(String[] args) { List<Long> squares = LongStream.iterate(1L, number -> number + 1) .map(number -> number * number) .limit(5) .boxed() .toList(); System.out.println(squares); // Stream.iterate is the counterpart of successors. List<Long> doubling = Stream.iterate(1L, previous -> previous * 2) .limit(6) .toList(); System.out.println(doubling); } }
Stream.iterate also has a three-argument form — seed, predicate, next — which is a bounded loop written as a stream and has no direct Rust equivalent short of successors returning None. The boxed() call is the erasure tax again: LongStream yields primitive long values and toList() needs objects, so the conversion has to be requested. Rust's (1u64..) is an ordinary value of type RangeFrom, which is why it composes with everything.
Concurrency
Starting a thread
Both spawn a real operating-system thread. The difference is what join gives back: Rust hands over the closure's return value, Java hands over nothing at all.
use std::thread; fn main() { let worker = thread::spawn(|| { let total: u64 = (1..=1000).sum(); println!("worker computed {total}"); total }); println!("main is doing its own work"); // join returns the closure's value, wrapped so a panic is visible. let result = worker.join().unwrap(); println!("main received {result}"); }
class Main { public static void main(String[] args) throws InterruptedException { // A Runnable returns nothing, so the result comes back through // a field or an array rather than from join(). long[] result = new long[1]; Thread worker = Thread.ofPlatform().start(() -> { long total = 0; for (int number = 1; number <= 1000; number++) { total += number; } System.out.println("worker computed " + total); result[0] = total; }); System.out.println("main is doing its own work"); worker.join(); // returns void System.out.println("main received " + result[0]); } }
A Java thread runs a Runnable, whose run method returns void, so a result has to be written somewhere both threads can see — here a one-element array, because a lambda cannot assign to a captured local. The idiomatic fix is an ExecutorService and a Future, which does return a value. Note throws InterruptedException on main: join is a blocking call, and every blocking call in Java is checked-exception territory.
No Send, no Sync — a data race compiles
There is no Send and no Sync. Any object may be reached from any thread, and the compiler asks no questions — so the Java program below is a genuine data race that compiles, runs, and usually prints the wrong number.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // Sharing across threads REQUIRES a type that is Sync. A bare // i32 behind an Rc would be rejected at compile time: // error[E0277]: an Rc cannot be sent between threads safely let counter = Arc::new(Mutex::new(0)); let handle = { let counter = Arc::clone(&counter); thread::spawn(move || { for _ in 0..50_000 { *counter.lock().unwrap() += 1; } }) }; for _ in 0..50_000 { *counter.lock().unwrap() += 1; } handle.join().unwrap(); println!("total = {}", *counter.lock().unwrap()); }
class Counter { int value; // no volatile, no lock, no complaint } class Main { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread worker = Thread.ofPlatform().start(() -> { for (int index = 0; index < 50_000; index++) { counter.value++; } }); for (int index = 0; index < 50_000; index++) { counter.value++; } worker.join(); System.out.println("total = " + counter.value); System.out.println("expected 100000 — updates were lost if it is lower"); } }
This is the guarantee you are giving up, stated as plainly as it can be. counter.value++ is a read, an add and a write, and two threads interleaving those steps lose updates; the result is not merely unsynchronized but undefined under the Java memory model, so the value may also be stale indefinitely. Rust encodes the same rules in two auto-traits and refuses to compile the program. Java's equivalent protections — synchronized, volatile, the java.util.concurrent.atomic package — are all opt-in, and the next rows are where they go.
Mutex and synchronized
Every Java object has a monitor, and synchronized acquires it for the duration of a block. The lock is therefore a convention about an object, not a container holding one.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // The data lives INSIDE the lock, so there is no way to reach it // without locking first. let ledger = Arc::new(Mutex::new(Vec::new())); let handle = { let ledger = Arc::clone(&ledger); thread::spawn(move || { for entry in 0..3 { ledger.lock().unwrap().push(format!("worker {entry}")); } }) }; for entry in 0..3 { ledger.lock().unwrap().push(format!("main {entry}")); } handle.join().unwrap(); println!("{} entries", ledger.lock().unwrap().len()); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) throws InterruptedException { // The lock is a separate object, and NOTHING enforces the // connection between it and the data it is meant to protect. List<String> ledger = new ArrayList<>(); Object lock = new Object(); Thread worker = Thread.ofPlatform().start(() -> { for (int entry = 0; entry < 3; entry++) { synchronized (lock) { ledger.add("worker " + entry); } } }); for (int entry = 0; entry < 3; entry++) { synchronized (lock) { ledger.add("main " + entry); } } worker.join(); synchronized (lock) { System.out.println(ledger.size() + " entries"); } } }
That is the whole difference and it is a large one. Mutex<Vec<String>> makes the data unreachable except through lock(); here, any code that forgets the synchronized block reaches the list anyway and nothing complains. There is also no poisoning: if a thread dies inside a synchronized block, the monitor is released and the next thread proceeds with whatever half-finished state it left, where Rust's lock() would return an Err. ReentrantLock in java.util.concurrent is the explicit alternative, with tryLock and timeouts that synchronized does not offer.
Atomics
This is the closest the two concurrency stories come to each other. An atomic counter is lock-free in both, and both are correct for the same reason.
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; fn main() { let counter = Arc::new(AtomicUsize::new(0)); let handle = { let counter = Arc::clone(&counter); thread::spawn(move || { for _ in 0..50_000 { counter.fetch_add(1, Ordering::Relaxed); } }) }; for _ in 0..50_000 { counter.fetch_add(1, Ordering::Relaxed); } handle.join().unwrap(); println!("total = {}", counter.load(Ordering::SeqCst)); }
import java.util.concurrent.atomic.AtomicLong; class Main { public static void main(String[] args) throws InterruptedException { AtomicLong counter = new AtomicLong(); Thread worker = Thread.ofPlatform().start(() -> { for (int index = 0; index < 50_000; index++) { counter.incrementAndGet(); } }); for (int index = 0; index < 50_000; index++) { counter.incrementAndGet(); } worker.join(); System.out.println("total = " + counter.get()); } }
The one visible difference is memory ordering: Rust makes you name it on every operation, so Relaxed versus SeqCst is a decision you cannot avoid making, while Java's incrementAndGet is sequentially consistent and there is nothing to pass. The weaker orderings do exist in Java, under VarHandle, but almost nobody reaches for them. Note also that no Arc is needed — the collector already keeps the object alive as long as either thread can see it, which is the one place garbage collection is unambiguously simpler.
Channels and blocking queues
A BlockingQueue is Java's channel: put blocks when it is full, take blocks when it is empty. What it does not have is an end.
use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); thread::spawn(move || { for job in 1..=3 { sender.send(format!("job {job}")).unwrap(); } // Dropping the sender is what ends the loop below. }); for message in receiver { println!("received {message}"); } println!("channel closed"); }
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; class Main { private static final String END_OF_WORK = "__done__"; public static void main(String[] args) throws InterruptedException { BlockingQueue<String> queue = new ArrayBlockingQueue<>(8); Thread.ofPlatform().start(() -> { try { for (int job = 1; job <= 3; job++) { queue.put("job " + job); } // A queue has no "closed" state, so the end must be // signaled by a value the receiver agrees to recognize. queue.put(END_OF_WORK); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } }); while (true) { String message = queue.take(); if (message.equals(END_OF_WORK)) { break; } System.out.println("received " + message); } System.out.println("queue drained"); } }
Rust's channel closes when the last Sender is dropped, which is what lets a for loop over the receiver terminate on its own — ownership doing double duty as a lifecycle signal. Java has no such moment, so a producer signals completion with an agreed sentinel value or a separate flag, and getting that wrong is how a consumer thread ends up blocked forever. The queue is also many-to-many rather than multi-producer-single-consumer, and it is bounded here by its constructor argument, which is the equivalent of sync_channel.
Virtual threads
Virtual threads, final in Java 21, are the JVM's answer to async without async: ordinary blocking code, scheduled cooperatively onto a handful of real threads. There is no function coloring, no runtime to choose, and no await.
use std::thread; // Rust's std threads are OS threads: about 8 KiB of stack reserved // each on Linux, and a context switch handled by the kernel. Ten // thousand of them is not something you would write. fn main() { let handles: Vec<_> = (0..8) .map(|worker| { thread::spawn(move || format!("worker {worker} finished")) }) .collect(); for handle in handles { println!("{}", handle.join().unwrap()); } println!("Async Rust is the other answer: futures on a runtime"); println!("such as Tokio, with no OS thread per task."); }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) throws InterruptedException { // A virtual thread is scheduled by the JVM onto a small pool of // carrier threads. Blocking one parks it instead of blocking // an OS thread, so a million of them is reasonable. List<Thread> workers = new ArrayList<>(); for (int worker = 0; worker < 10_000; worker++) { int id = worker; workers.add(Thread.ofVirtual().start(() -> { try { Thread.sleep(10); // parks; costs no OS thread } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } if (id == 0) { System.out.println("worker 0 finished"); } })); } for (Thread worker : workers) { worker.join(); } System.out.println("10000 virtual threads completed"); } }
This is the concurrency feature with no Rust counterpart in the standard library. Rust's answer is async/await over an executor such as Tokio, which achieves the same density but splits the language in two — an async fn can only be called from another async fn, and the blocking and non-blocking halves of the ecosystem stay separate. Java kept one kind of code and moved the cleverness into the scheduler. 🚨 The Java column here shows correct code with no Run button: Compiler Explorer's sandbox allows one operating-system thread beyond main, and a virtual thread needs two — a carrier plus a VirtualThread-unblocker — so virtual threads cannot start there at any count.
Implicit context: thread locals and scoped values
Both languages have a way to carry context without threading it through every signature. Java's newest one is bound for the duration of a call rather than assigned to a slot.
use std::cell::RefCell; thread_local! { static REQUEST_ID: RefCell<String> = RefCell::new(String::from("none")); } fn log(message: &str) { REQUEST_ID.with(|id| println!("[{}] {message}", id.borrow())); } fn main() { log("before"); REQUEST_ID.with(|id| *id.borrow_mut() = String::from("abc-123")); log("during"); // Nothing restores the old value automatically — the write // stays until something overwrites it. REQUEST_ID.with(|id| *id.borrow_mut() = String::from("none")); log("after"); }
class Main { // Final in Java 25. A scoped value is immutable and its binding // lasts exactly as long as the run() call — it cannot leak. private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance(); static void log(String message) { String id = REQUEST_ID.isBound() ? REQUEST_ID.get() : "none"; System.out.println("[" + id + "] " + message); } public static void main(String[] args) { log("before"); ScopedValue.where(REQUEST_ID, "abc-123").run(() -> log("during")); log("after"); } }
The older Java mechanism, ThreadLocal, is the direct counterpart of thread_local! — a mutable per-thread slot, with the same problem visible above: someone has to remember to put it back, and forgetting leaks the value into whatever runs next on that thread. A ScopedValue is immutable and its lifetime is the run call, so it unwinds by itself, and it costs nothing per virtual thread — which is why it arrived alongside them. Rust has no equivalent of the scoped form; passing the value explicitly is the idiom.