Skip to content
Learn Rust
Theme

Rust for Java developers

You know Java well, so a lot of Rust will feel familiar. This page covers the parts that do not. Every Java program here ran with javac and java from Temurin JDK 25.0.5, and every Rust program ran with Rust 1.99.1, in the image behind this site (Linux ARM64). Maven and Gradle are not in the image, so anything about them is marked not run. Compile the Java files one at a time: two of them declare a Point.

RememberRust checks things that Java leaves to convention: who owns a value, who may change it, and what may cross a thread.

  • Static types, inference and generics with bounds. <T extends Comparable<T>> becomes T: PartialOrd (Generics).
  • Interfaces and lambdas. A trait is an interface with default methods, and a closure is a lambda that may also change what it captured (Traits, Closures).
  • Streams. iter().filter().map().collect() is a lazy pipeline, like a Stream (Iterators).
  • Records and sealed interfaces. A struct with derives is a record, and an enum with data plus match is a sealed interface with a pattern switch (Enums).
  • Cleanup at the end of a block. try-with-resources is the nearest idea to what Rust does for every value (habit 7).

Each habit has the Java program on the left and what Rust does on the right. Where Rust refuses the Java shape, the right side is the real compiler error; fixes are in the idiom table or Try it yourself. The compiler errors name src/bin/fail.rs because each failing program was copied to that file to build it.

A subclass inherits fields and methods, and a User variable can hold an Admin. Rust has no extends (No inheritance). The Admin holds a User, and a trait plays the part of the interface.

JavaInherit.java
class User {
String name;
User(String name) {
this.name = name;
}
String describe() {
return "user " + name;
}
}
class Admin extends User {
Admin(String name) {
super(name);
}
@Override
String describe() {
return "admin " + super.describe();
}
}
public class Inherit {
public static void main(String[] args) {
User u = new Admin("ana");
System.out.println(u.describe());
System.out.println(u.name);
}
}
$ javac Inherit.java && java Inherit
admin user ana
ana
Rustsrc/bin/inherit.rs
trait Describe {
fn describe(&self) -> String;
}
struct User {
name: String,
}
struct Admin {
user: User,
}
impl Describe for User {
fn describe(&self) -> String {
format!("user {}", self.name)
}
}
impl Describe for Admin {
fn describe(&self) -> String {
format!("admin {}", self.user.describe())
}
}
fn show(thing: &dyn Describe) {
println!("{}", thing.describe());
}
fn main() {
let admin = Admin {
user: User {
name: String::from("ana"),
},
};
show(&admin);
println!("{}", admin.user.name);
}
$ cargo run --bin inherit
admin user ana
ana

super.describe() became self.user.describe(), and u.name became admin.user.name. You write the path. Shared behaviour goes in a trait’s default methods, shared data in a field (Traits).

ages.get("bo") returned null, and unboxing it into an int threw at run time, after the first line had printed. Rust’s get returns an Option, and an Option<&i32> is not an i32, so this does not compile.

JavaNulls.java
import java.util.HashMap;
import java.util.Map;
public class Nulls {
public static void main(String[] args) {
Map<String, Integer> ages = new HashMap<>();
ages.put("ana", 31);
int ana = ages.get("ana");
System.out.println("ana: " + ana);
int bo = ages.get("bo");
System.out.println("bo: " + bo);
}
}
$ javac Nulls.java && java Nulls
ana: 31
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
	at Nulls.main(Nulls.java:10)
Rustnull.rs
use std::collections::HashMap;
fn main() {
let mut ages = HashMap::new();
ages.insert("ana", 31);
let ana: i32 = ages.get("ana");
println!("ana: {ana}");
}
$ cargo run --bin fail
error[E0308]: mismatched types
 --> src/bin/fail.rs:6:20
  |
6 |     let ana: i32 = ages.get("ana");
  |              ---   ^^^^^^^^^^^^^^^ expected `i32`, found `Option<&{integer}>`
  |              |
  |              expected due to this
  |
  = note: expected type `i32`
             found enum `Option<&{integer}>`

You decide what a missing key means: unwrap_or(0), a match, or an if let (Option). Java’s Optional exists, but null stays a legal value of a variable of type Optional too, as the Option lesson runs. Task 2 below runs the fix.

In Java List<String> b = a copies the reference, and the == line shows one object. Rust moves the Vec into b, and using a afterwards is error E0382.

JavaAlias.java
import java.util.ArrayList;
import java.util.List;
public class Alias {
public static void main(String[] args) {
List<String> a = new ArrayList<>();
a.add("x");
List<String> b = a;
b.add("y");
System.out.println(a);
System.out.println(a == b);
}
}
$ javac Alias.java && java Alias
[x, y]
true
Rustmoved.rs
fn main() {
let a = vec![String::from("x")];
let mut b = a;
b.push(String::from("y"));
println!("{a:?}");
}
$ cargo run --bin fail
error[E0382]: borrow of moved value: `a`
 --> src/bin/fail.rs:5:16
  |
2 |     let a = vec![String::from("x")];
  |         - move occurs because `a` has type `Vec<String>`, which does not implement the `Copy` trait
3 |     let mut b = a;
  |                 - value moved here
4 |     b.push(String::from("y"));
5 |     println!("{a:?}");
  |                ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let mut b = a.clone();
  |                  ++++++++

If you want two lists, call clone(). If you want two names for one list, borrow it with &mut. Task 1 runs both. See Ownership and Copy or reference?.

final stops the variable from pointing at another list. The list itself still took two add calls. In Rust let freezes the value too, unless the type allows interior mutation (Smart pointers).

JavaFinal.java
import java.util.ArrayList;
import java.util.List;
public class Final {
public static void main(String[] args) {
final List<String> names = new ArrayList<>();
names.add("ana");
names.add("bo");
System.out.println(names);
}
}
$ javac Final.java && java Final
[ana, bo]
Rustimmutable.rs
fn main() {
let names = Vec::new();
names.push("ana");
println!("{names:?}");
}
$ cargo run --bin fail
error[E0596]: cannot borrow `names` as mutable, as it is not declared as mutable
 --> src/bin/fail.rs:3:5
  |
3 |     names.push("ana");
  |     ^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut names = Vec::new();
  |         +++

let mut opts in. The same split shows in signatures: &Vec<T> can read, &mut Vec<T> can change (Variables, Mutable borrows).

Integer.parseInt declares nothing, so javac did not ask for a catch, and the failure arrived as a stack trace. Rust’s parse returns a Result, and using it as a u16 does not compile.

JavaParse.java
public class Parse {
static int port(String text) {
return Integer.parseInt(text);
}
public static void main(String[] args) {
System.out.println(port("8080"));
System.out.println(port("eighty"));
}
}
$ javac Parse.java && java Parse
8080
Exception in thread "main" java.lang.NumberFormatException: For input string: "eighty"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:565)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at Parse.port(Parse.java:3)
	at Parse.main(Parse.java:8)
Rustresult.rs
fn port(text: &str) -> u16 {
text.parse()
}
fn main() {
println!("{}", port("8080"));
}
$ cargo run --bin fail
error[E0308]: mismatched types
 --> src/bin/fail.rs:2:5
  |
1 | fn port(text: &str) -> u16 {
  |                        --- expected `u16` because of return type
2 |     text.parse()
  |     ^^^^^^^^^^^^ expected `u16`, found `Result<_, _>`
  |
  = note: expected type `u16`
             found enum `Result<_, _>`
help: consider using `Result::expect` to unwrap the `Result<_, _>` value, panicking if the value is a `Result::Err`
  |
2 |     text.parse().expect("REASON")
  |                 +++++++++++++++++

The failure is part of the return type, so every caller sees it. Handle it with match, give it a default (row 12 of the table), or pass it up with ? (the stack-traces bullet below); see Errors. Java’s checked exceptions are the nearest relative, but a Result is an ordinary value that you can store or collect.

6. Return whatever you have, and let the GC keep it alive

Section titled “6. Return whatever you have, and let the GC keep it alive”

longest returned one of its two arguments, and the program printed the result even though inner, a new String on the heap, had gone out of scope: the object lives while something refers to it. Rust has no collector to keep a borrowed string alive, so the signature has to say which input the result borrows from.

JavaLongest.java
public class Longest {
static String longest(String a, String b) {
return a.length() >= b.length() ? a : b;
}
public static void main(String[] args) {
String result;
{
String inner = new String("a longer string");
result = longest("short", inner);
}
System.out.println(result);
}
}
$ javac Longest.java && java Longest
a longer string
Rustlongest.rs
fn longest(a: &str, b: &str) -> &str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
println!("{}", longest("short", "a longer string"));
}
$ cargo run --bin fail
error[E0106]: missing lifetime specifier
 --> src/bin/fail.rs:1:33
  |
1 | fn longest(a: &str, b: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

The compiler’s suggestion, <'a>, ties the result to both inputs. If a function builds new text, return an owned String and skip lifetimes (Returning references, Lifetimes).

7. Close resources with try, and only there

Section titled “7. Close resources with try, and only there”

a and b were closed in reverse order because the program used try-with-resources. c was opened without it, and in this program nothing ever printed close c. In Rust a value is dropped when its owner goes out of scope, so _c was closed at the end of main and nobody had to remember anything.

JavaCleanup.java
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
System.out.println("open " + name);
}
@Override
public void close() {
System.out.println("close " + name);
}
}
public class Cleanup {
public static void main(String[] args) {
try (Res a = new Res("a"); Res b = new Res("b")) {
System.out.println("working");
}
Res c = new Res("c");
System.out.println("done");
}
}
$ javac Cleanup.java && java Cleanup
open a
open b
working
close b
close a
open c
done
Rustsrc/bin/cleanup.rs
struct Res(&'static str);
impl Res {
fn open(name: &'static str) -> Res {
println!("open {name}");
Res(name)
}
}
impl Drop for Res {
fn drop(&mut self) {
println!("close {}", self.0);
}
}
fn main() {
{
let _a = Res::open("a");
let _b = Res::open("b");
println!("working");
}
let _c = Res::open("c");
println!("done");
}
$ cargo run --bin cleanup
open a
open b
working
close b
close a
open c
done
close c

You write the Drop once, for the type (Drop). See the Java Tutorials on try-with-resources.

At run time a List<String> and a List<Integer> were the same class (the first two lines: that is erasure). The last line is boxing, not erasure: a List cannot hold an int, so the two 1000s became separate Integer objects and == compared references. Rust’s generics are not erased: each Vec<T> is compiled for its own T, and == compares values.

JavaErasure.java
import java.util.ArrayList;
import java.util.List;
public class Erasure {
public static void main(String[] args) {
List<String> words = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
numbers.add(1000);
numbers.add(1000);
System.out.println(words.getClass() == numbers.getClass());
System.out.println(words.getClass().getName());
System.out.println(numbers.get(0) == numbers.get(1));
}
}
$ javac Erasure.java && java Erasure
true
java.util.ArrayList
false
Rustsrc/bin/generics.rs
use std::any::type_name;
fn element_type<T>(_: &Vec<T>) -> &'static str {
type_name::<T>()
}
fn main() {
let words: Vec<String> = Vec::new();
let numbers: Vec<i32> = vec![1000, 1000, 7];
println!("{} and {}", element_type(&words), element_type(&numbers));
let gap = numbers[1..].as_ptr().addr() - numbers.as_ptr().addr();
println!("bytes between neighbours: {gap}");
println!("{}", numbers[0] == numbers[1]);
}
$ cargo run --bin generics
alloc::string::String and i32
bytes between neighbours: 4
true

The names that type_name prints are for diagnostics, and its docs do not promise their text. The gap of 4 bytes comes from the i32 size (Generics; the Java Tutorials describe erasure).

9. Write getters, setters and equals by hand

Section titled “9. Write getters, setters and equals by hand”

A plain class with private fields and getters is not equal to another one with the same numbers until you write equals and hashCode; a record writes them. A Rust struct has no equality either, and the compiler says so instead of answering false.

JavaRecords.java
class PointClass {
private final int x;
private final int y;
PointClass(int x, int y) {
this.x = x;
this.y = y;
}
int getX() { return x; }
int getY() { return y; }
}
record Point(int x, int y) {}
public class Records {
public static void main(String[] args) {
System.out.println(new PointClass(1, 2).equals(new PointClass(1, 2)));
System.out.println(new Point(1, 2).equals(new Point(1, 2)));
System.out.println(new Point(1, 2));
}
}
$ javac Records.java && java Records
false
true
Point[x=1, y=2]
Rustequals.rs
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = Point { x: 1, y: 2 };
println!("{}", a == b);
}
$ cargo run --bin fail
error[E0369]: binary operation `==` cannot be applied to type `Point`
 --> src/bin/fail.rs:9:22
  |
9 |     println!("{}", a == b);
  |                    - ^^ - Point
  |                    |
  |                    Point
  |
note: an implementation of `PartialEq` might be missing for `Point`
 --> src/bin/fail.rs:1:1
  |
1 | struct Point {
  | ^^^^^^^^^^^^ must implement `PartialEq`
help: consider annotating `Point` with `#[derive(PartialEq)]`
  |
1 + #[derive(PartialEq)]
2 | struct Point {

One line of derive writes the rest:

Rustsrc/bin/derive.rs
#[derive(Debug, PartialEq, Eq, Hash)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = Point { x: 1, y: 2 };
println!("{}", a == b);
println!("{a:?}");
}
$ cargo run --bin derive
true
Point { x: 1, y: 2 }

Fields are private to their module, not to the type, so code in the same module can read p.x (Structs, Modules, Equality and hashing).

10. Share a list between threads and add a lock later

Section titled “10. Share a list between threads and add a lock later”

Four threads added to one ArrayList. javac did not object, and the run printed a smaller count than the 400,000 expected, or one thread died with ArrayIndexOutOfBoundsException (this output varies between runs). In Rust the same shape does not compile: the Vec moves into the first thread’s closure.

JavaRacy.java
import java.util.ArrayList;
import java.util.List;
public class Racy {
public static void main(String[] args) throws InterruptedException {
List<Integer> results = new ArrayList<>();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int n = i;
threads.add(new Thread(() -> {
for (int j = 0; j < 100_000; j++) {
results.add(n * 10);
}
}));
}
threads.forEach(Thread::start);
for (var t : threads) {
t.join();
}
System.out.println(results.size() + " items (expected 400000)");
}
}
$ javac Racy.java && java Racy
103148 items (expected 400000)

This output changes from run to run. This is one real run.

Rustspawn.rs
use std::thread;
fn main() {
let mut results = Vec::new();
let mut handles = Vec::new();
for i in 0..4 {
handles.push(thread::spawn(move || {
for _ in 0..100_000 {
results.push(i * 10);
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("{} items", results.len());
}
$ cargo run --bin fail
error[E0382]: borrow of moved value: `results`
  --> src/bin/fail.rs:16:26
   |
 4 |     let mut results = Vec::new();
   |         ----------- move occurs because `results` has type `Vec<i32>`, which does not implement the `Copy` trait
 5 |     let mut handles = Vec::new();
 6 |     for i in 0..4 {
   |     ------------- inside of this loop
 7 |         handles.push(thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
 8 |             for _ in 0..100_000 {
   |             ------------------- inside of this loop
...
16 |     println!("{} items", results.len());
   |                          ^^^^^^^ value borrowed here after move
   |
help: consider cloning the value before moving it into the closure
   |
 7 ~         let value = results.clone();
 8 ~         handles.push(thread::spawn(move || {
 9 |             for _ in 0..100_000 {
10 ~                 value.push(i * 10);

To share it, you wrap it in Arc<Mutex<_>>, and the count is right:

Rustsrc/bin/shared.rs
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let results = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
for i in 0..4 {
let results = Arc::clone(&results);
handles.push(thread::spawn(move || {
for _ in 0..100_000 {
results.lock().unwrap().push(i * 10);
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("{} items (expected 400000)", results.lock().unwrap().len());
}
$ cargo run --bin shared
400000 items (expected 400000)

Send and Sync explains why safe Rust code cannot contain a data race. It does not stop deadlocks, or a wrong check-then-act across two locks (Shared state).

Every cell was run: the Java cells in one program, the Rust cells in another. Open the box below the table to see both and what they printed. Where a program spreads a cell over several lines, the table shows it on one.

# You write in Java In Rust Lesson
1 final var x = 1; let x = 1; Variables
2 count++; count += 1; What Rust doesn’t have
3 String size = n > 5 ? "big" : "small"; let size = if n > 5 { "big" } else { "small" }; If / else
4 String level = switch (n) { case 1, 2 -> "low"; default -> "high"; }; let level = match n { 1 | 2 => "low", _ => "high" }; Match
5 Optional.of("hi").map(String::length).orElse(0) Some("hi").map(|s| s.len()).unwrap_or(0) Option
6 ages.getOrDefault("bo", 0) ages.get("bo").copied().unwrap_or(0) Maps
7 counts.merge("a", 1, Integer::sum); *counts.entry("a").or_insert(0) += 1; Maps
8 nums.stream().filter(m -> m % 2 == 0).map(m -> m * m).toList() nums.iter().filter(|n| **n % 2 == 0).map(|n| n * n).collect::<Vec<_>>() Iterators
9 for (String s : names) for s in &names Looping over a collection
10 String.join(", ", names) names.join(", ") Working with text
11 word.length() word.chars().count() (word.len() counts bytes) Strings
12 try { parsed = Integer.parseInt(s); } catch (NumberFormatException e) { parsed = -1; } let parsed = s.parse::<i32>().unwrap_or(-1); Errors
13 record Point(int x, int y) {} #[derive(Debug, PartialEq, Eq, Hash)] struct Point { x: i32, y: i32 } Common traits
14 if (shape instanceof Circle c) if let Shape::Circle(r) = shape Enums
15 default String hi() { return "hi"; } fn hi(&self) -> String { String::from("hi") } Traits
16 new ArrayList<>(original) original.clone() Ownership
17 CompletableFuture.supplyAsync(() -> 5).join() thread::spawn(|| 5).join().unwrap() Threads
18 synchronized (lock) { box[0]++; } *count.lock().unwrap() += 1; Shared state
19 (int) 3.9 3.9_f64 as i32 Numbers and types
20 "x=" + n format!("x={n}") Printing and formatting

Rows 11, 17 and 18 are not exact twins. Row 11: length() in Java counts UTF-16 units, and Rust’s chars() counts Unicode scalar values. Both gave 5 for héllo, while len() gave 6. Row 17: supplyAsync ran the work on a pool (the 17 pool line printed ForkJoinPool.commonPool-worker-1), and thread::spawn starts an operating-system thread. Row 18: in Rust the Mutex owns the number, and in Java the lock is a separate object next to it.

The two programs behind the table
JavaIdioms.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square() implements Shape {}
record Point(int x, int y) {}
interface Greeter {
default String hi() { return "hi"; }
}
class English implements Greeter {}
public class Idioms {
public static void main(String[] args) {
final var x = 1;
System.out.println("1: " + x);
int count = 0;
count++;
System.out.println("2: " + count);
int n = 7;
String size = n > 5 ? "big" : "small";
System.out.println("3: " + size);
String level = switch (n) { case 1, 2 -> "low"; default -> "high"; };
System.out.println("4: " + level);
int len = Optional.of("hi").map(String::length).orElse(0);
System.out.println("5: " + len);
Map<String, Integer> ages = new HashMap<>();
ages.put("ana", 31);
int bo = ages.getOrDefault("bo", 0);
System.out.println("6: " + bo);
Map<String, Integer> counts = new HashMap<>();
counts.merge("a", 1, Integer::sum);
System.out.println("7: " + counts.get("a"));
List<Integer> nums = List.of(1, 2, 3, 4);
List<Integer> squares = nums.stream().filter(m -> m % 2 == 0).map(m -> m * m).toList();
System.out.println("8: " + squares);
List<String> names = List.of("ana", "bo");
StringBuilder seen = new StringBuilder();
for (String s : names) {
seen.append(s);
}
System.out.println("9: " + seen);
System.out.println("10: " + String.join(", ", names));
String word = "héllo";
System.out.println("11: " + word.length() + " chars");
String s = "eighty";
int parsed;
try { parsed = Integer.parseInt(s); } catch (NumberFormatException e) { parsed = -1; }
System.out.println("12: " + parsed);
System.out.println("13: " + new Point(1, 2).equals(new Point(1, 2)));
for (Shape shape : List.of(new Circle(2.5), new Square())) {
if (shape instanceof Circle c) {
System.out.println("14: circle, radius " + c.radius());
} else {
System.out.println("14: not a circle");
}
}
System.out.println("15: " + new English().hi());
List<String> original = List.of("a", "b");
List<String> copy = new ArrayList<>(original);
copy.add("c");
System.out.println("16: " + original.size() + " " + copy.size());
System.out.println("17: " + CompletableFuture.supplyAsync(() -> 5).join());
System.out.println("17 pool: " + CompletableFuture.supplyAsync(() -> Thread.currentThread().getName()).join());
Object lock = new Object();
int[] box = {0};
synchronized (lock) { box[0]++; }
System.out.println("18: " + box[0]);
System.out.println("19: " + (int) 3.9);
System.out.println("20: " + "x=" + n);
}
}
$ javac Idioms.java && java Idioms
1: 1
2: 1
3: big
4: high
5: 2
6: 0
7: 1
8: [4, 16]
9: anabo
10: ana, bo
11: 5 chars
12: -1
13: true
14: circle, radius 2.5
14: not a circle
15: hi
16: 2 3
17: 5
17 pool: ForkJoinPool.commonPool-worker-1
18: 1
19: 3
20: x=7
Rustsrc/bin/idioms.rs
use std::collections::HashMap;
use std::sync::Mutex;
use std::thread;
#[derive(Debug, PartialEq, Eq, Hash)]
struct Point {
x: i32,
y: i32,
}
enum Shape {
Circle(f64),
Square,
}
trait Greeter {
fn hi(&self) -> String {
String::from("hi")
}
}
struct English;
impl Greeter for English {}
fn main() {
let x = 1;
println!("1: {x}");
let mut count = 0;
count += 1;
println!("2: {count}");
let n = 7;
let size = if n > 5 { "big" } else { "small" };
println!("3: {size}");
let level = match n {
1 | 2 => "low",
_ => "high",
};
println!("4: {level}");
let len = Some("hi").map(|s| s.len()).unwrap_or(0);
println!("5: {len}");
let mut ages = HashMap::new();
ages.insert("ana", 31);
let bo = ages.get("bo").copied().unwrap_or(0);
println!("6: {bo}");
let mut counts = HashMap::new();
*counts.entry("a").or_insert(0) += 1;
println!("7: {}", counts["a"]);
let nums = vec![1, 2, 3, 4];
let squares = nums
.iter()
.filter(|n| **n % 2 == 0)
.map(|n| n * n)
.collect::<Vec<_>>();
println!("8: {squares:?}");
let names = vec!["ana", "bo"];
let mut seen = String::new();
for s in &names {
seen.push_str(s);
}
println!("9: {seen}");
println!("10: {}", names.join(", "));
let word = "héllo";
println!(
"11: {} chars, len() is {}",
word.chars().count(),
word.len()
);
let s = "eighty";
let parsed = s.parse::<i32>().unwrap_or(-1);
println!("12: {parsed}");
let a = Point { x: 1, y: 2 };
let b = Point { x: 1, y: 2 };
println!("13: {}", a == b);
for shape in [Shape::Circle(2.5), Shape::Square] {
if let Shape::Circle(r) = shape {
println!("14: circle, radius {r}");
} else {
println!("14: not a circle");
}
}
println!("15: {}", English.hi());
let original = vec!["a", "b"];
let mut copy = original.clone();
copy.push("c");
println!("16: {} {}", original.len(), copy.len());
println!("17: {}", thread::spawn(|| 5).join().unwrap());
let count = Mutex::new(0);
*count.lock().unwrap() += 1;
println!("18: {}", count.lock().unwrap());
println!("19: {}", 3.9_f64 as i32);
println!("20: {}", format!("x={n}"));
}
$ cargo run --bin idioms
1: 1
2: 1
3: big
4: high
5: 2
6: 0
7: 1
8: [4, 16]
9: anabo
10: ana, bo
11: 5 chars, len() is 6
12: -1
13: true
14: circle, radius 2.5
14: not a circle
15: hi
16: 2 3
17: 5
18: 1
19: 3
20: x=7

Cargo is the build tool, and also the front door to the compiler, the formatter, the linter and the documentation generator. The commands below ran on a two-function project (one Java file, one Rust crate with a unit test and a doc-test).

Job Java Rust Run?
New project mvn archetype:generate, gradle init cargo new demo Cargo run. Maven and Gradle not run
Build file pom.xml, build.gradle Cargo.toml Not run for Java
Compile javac Slug.java cargo build Both run
Run java Slug, or java Slug.java cargo run Both run
Test JUnit through Maven or Gradle cargo test Rust run. JUnit not run
Format a plugin or tool such as Spotless or google-java-format cargo fmt --check Rust run. Java not run
Lint javac -Xlint:all (the compiler’s own warnings, not a linter) cargo clippy Both run
Docs javadoc cargo doc Both run
Dependencies coordinates in pom.xml, from Maven Central cargo add, from crates.io (Using crates) Not run
Debugger jdb rust-gdb, rust-lldb (wrappers for gdb and lldb) jdb ran with -version. No debugger session run: gdb and lldb are not in the image
REPL jshell none in cargo --list jshell found; no Cargo command mentions a REPL
The Rust and Java commands that ran

An empty output means the command printed nothing. For cargo fmt --check and cargo clippy that is the pass result in this run, and javac is quiet when there is nothing to report.

Rust
$ cargo new demo
    Creating binary (application) `demo` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cargo build
$ cargo test
running 2 tests
test tests::joins_words ... ok
test tests::ignores_extra_spaces ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests slugtools

running 1 test
test src/lib.rs - slug (line 3) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.12s; merged doctests compilation took 0.12s
$ cargo fmt --check
$ cargo clippy
$ cargo doc --no-deps
 Documenting slugtools v0.1.0 (/home/you/habits)

   Generated target/doc/slugtools/index.html
$ cargo run
hello-rust-world

This output changes from run to run. This is one real run.

Rustsrc/lib.rs
/// Turns a title into a URL slug: lowercase words joined by `-`.
///
/// ```
/// assert_eq!(slugtools::slug("Hello Rust World"), "hello-rust-world");
/// ```
pub fn slug(title: &str) -> String {
title
.split_whitespace()
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_words() {
assert_eq!(slug("Hello Rust World"), "hello-rust-world");
}
#[test]
fn ignores_extra_spaces() {
assert_eq!(slug(" Hello Rust "), "hello-rust");
}
}
Java
$ javac -Xlint:all Slug.java
$ java Slug
hello-java-world
$ java Slug.java
hello-java-world
$ javadoc -quiet -d docs Slug.java
$ command -v jshell
/opt/jdk/bin/jshell
$ jdb -version
This is jdb version 25.0 (Java SE version 25.0.5)
JavaSlug.java
/** Turns a title into a URL slug: lowercase words joined by "-". */
public class Slug {
private Slug() {}
/**
* Builds the slug.
*
* @param title the text to convert
* @return lowercase words joined by "-"
*/
public static String slug(String title) {
return String.join("-", title.trim().toLowerCase().split("\\s+"));
}
/**
* Prints the slug of a fixed title.
*
* @param args not used
*/
public static void main(String[] args) {
System.out.println(slug("Hello Java World"));
}
}

What the image does not have, and the Cargo commands that mention a REPL:

terminal
$ for t in mvn gradle gdb lldb rust-gdb; do command -v $t || echo "$t: not found"; done
mvn: not found
gradle: not found
gdb: not found
lldb: not found
/opt/cargo/bin/rust-gdb
$ cargo --list | grep -ci repl    # how many cargo commands mention a REPL
0

In this order, for a Java reader:

  1. What Rust doesn’t have: your reflexes (++, ?:, null, new, overloads) in one table.
  2. Values, the stack and the heap: a Java object variable is a reference; a Rust variable holds the value.
  3. Ownership, Borrowing and Mutable borrows: habits 3 and 4 are these three lessons.
  4. Structs and Methods: no constructors, no inheritance.
  5. Enums, Option and Errors: sealed interfaces, null and exceptions.
  6. Traits, Generics and Trait objects: interfaces, <T extends ...>, and List<Shape>.
  7. Closures and Iterators: lambdas and streams, with references in the items.
  8. Returning references and Lifetimes: where the garbage collector used to decide.
  9. Smart pointers and Working with the borrow checker: Rc for shared ownership, indices for graphs.
  10. Threads, Shared state and Send and Sync: habit 10 in full.
  11. Async basics: futures that start only when polled, compared with CompletableFuture and virtual threads.
  12. Project structure: Cargo workspaces next to Maven or Gradle multi-module builds.

If you also read Kotlin or C#, the same page exists for them: Rust for Kotlin developers and Rust for C# developers.

  • A REPL. jshell is in the JDK. In this image no cargo command mentions a REPL (the last session printed 0). Crates such as evcxr provide one; I did not run it.

  • Automatic stack traces. Habit 5’s Java failure printed five frames. A Rust main that returns an Err prints one line:

    Rustsrc/bin/noframes.rs
    fn main() -> Result<(), std::num::ParseIntError> {
    let port: u16 = "eighty".parse()?;
    println!("port {port}");
    Ok(())
    }
    $ cargo run --bin noframes
    Error: ParseIntError { kind: InvalidDigit }

    To get a backtrace, the anyhow guide runs a program with RUST_BACKTRACE=1.

  • Run-time reflection. The std::any module offers Any, TypeId, type_name and downcasting. Its docs call it utilities for “dynamic typing or type reflection”, and say &dyn Any is limited to testing for a concrete type. I found nothing there that lists a type’s fields or methods (std::any). Rust code generation happens at compile time, through macros (Macros you’ll read).

  • Overloading and default arguments. One name, one function. Use different names, a builder or a struct (the lesson).

  • Object graphs with back-pointers. A parent pointer or a cycle is just a field in Java. In Rust you choose Rc with Weak, or ids into a Vec (Cycles, Indices, not references).

I ran all three in the image.

  1. Fix habit 3 in two ways. Change let mut b = a; to let mut b = a.clone(); and a stays ["x"]. Make a mutable and write let b = &mut a; instead, and the push through b shows in a because they are one list:

    terminal
    $ cargo run --bin one    # with: let mut b = a.clone();
    ["x"]
    $ cargo run --bin two    # with: let mut a = vec![...]; let b = &mut a;
    ["x", "y"]
    
  2. Make the missing key a value. Replace the i32 line of habit 2 with ages.get("ana").copied().unwrap_or(0) and look up "bo" the same way. It compiles, and bo is 0:

    terminal
    $ cargo run --bin fixed    # with: .get("ana").copied().unwrap_or(0), and a lookup of "bo"
    ana: 31
    bo: 0
    
  3. Run both racing programs three times. Expect a Java count below 400000 that changes between runs (or an ArrayIndexOutOfBoundsException: 5 of 60 separate runs of mine, not shown here), and Rust’s 400000 each time:

    Java
    $ for i in 1 2 3; do java Racy; done
    122298 items (expected 400000)
    105329 items (expected 400000)
    103826 items (expected 400000)
    

    This output changes from run to run. This is one real run.

    Rust
    $ for i in 1 2 3; do cargo run --bin shared; done
    400000 items (expected 400000)
    400000 items (expected 400000)
    400000 items (expected 400000)