Output & Running
Hello, World — and the ceremony around it
Swift lets statements sit at file scope, so a script is a program. Java has no top level at all: every statement lives in a method, every method in a class, and the class name must match the file name.
print("Hello, World!")class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}Read the incantation left to right and it is all necessary in Java's model.
public lets the launcher, which is outside your class, call it; static means no instance has to exist first; void means the exit status comes from System.exit rather than a return; String[] args is CommandLine.arguments.dropFirst(). JEP 445 is relaxing this for single-file programs, but every codebase you will meet writes it out.Interpolation, and its absence
Java has no string interpolation. Concatenation with
+ is the everyday form, and printf or formatted is the one with control over the output.import Foundation
let name = "Ada"
let score = 91.5
print("\(name) scored \(score)")
print(String(format: "%@ scored %.1f", name, score))class Main {
public static void main(String[] args) {
String name = "Ada";
double score = 91.5;
System.out.println(name + " scored " + score);
System.out.printf("%s scored %.1f%n", name, score);
}
}A string template feature was previewed in Java 21 and 22 and then withdrawn — do not plan around it. Note
%n rather than \n: it emits the platform line separator, and printf adds nothing on its own. The compiler turns a chain of + into an efficient concatenation, so the old advice to reach for StringBuilder applies only inside loops.Printing a value you defined
Both languages call a method to render a value as text, and the difference is how much you have to write. Swift asks for
CustomStringConvertible; Java asks you to override toString — unless the type is a record, which generates one.struct Point: CustomStringConvertible {
let x: Int
let y: Int
var description: String { "Point(\(x), \(y))" }
}
let point = Point(x: 1, y: 2)
print(point)
print([point, Point(x: 3, y: 4)])record Point(int x, int y) {}
class Main {
public static void main(String[] args) {
Point point = new Point(1, 2);
System.out.println(point);
System.out.println(java.util.List.of(point, new Point(3, 4)));
}
}A class with no
toString prints something like Point@6d06d69c — the class name and an identity hash — which is the Java equivalent of a useless default and the reason record is such a relief. There is no separate debugDescription; one method serves both purposes. Collections print their elements by calling toString on each, so a list of records reads well for free.Value Semantics Are Gone
A struct copies; a Java object never does
This is the headline of the whole page, so it comes before anything else. Java has no value types beyond the eight primitives: every object is a reference, and every assignment makes a second name for one object.
struct Point {
var x: Int
var y: Int
}
var first = Point(x: 1, y: 2)
var second = first // a COPY
second.x = 99
print(first.x)class Point {
int x;
int y;
Point(int x, int y) { this.x = x; this.y = y; }
}
class Main {
public static void main(String[] args) {
Point first = new Point(1, 2);
Point second = first; // the SAME object
second.x = 99;
System.out.println(first.x);
}
}Swift prints
1 and Java prints 99, and no amount of care with final changes that. There is no struct, no copy-on-write, and no way to declare "this type has value semantics" — Project Valhalla's value classes are still not shipped. What you get instead is a discipline: make the type immutable so aliasing cannot be observed. That is what the next row is about, and it is the habit to adopt on day one.record is the nearest thing to a struct
A
record is a final class whose fields are final, with a constructor, equals, hashCode and toString generated. It is not a value type — it is still a reference — but immutability means you cannot tell the difference.struct Point: Equatable {
let x: Int
let y: Int
}
let first = Point(x: 1, y: 2)
let second = Point(x: 1, y: 2)
print(first == second)
print(first)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(first.equals(second));
System.out.println(first);
}
}That last sentence is the whole trick, and it is why records should be your default for data on the JVM. Note two things Swift gives you that a record does not. There is no memberwise
with-style copy: changing one field means new Point(first.x(), 99), spelled out. And the accessor is point.x(), a method, not a field — a record's components are private and final underneath. Swift's == becomes equals; == in Java is identity, which the strings section returns to.Handing out a collection hands out the collection
In Swift, returning an array from a property hands the caller a copy: whatever they do to it, your object is untouched. In Java it hands them your list, and they can empty it.
struct Basket {
private(set) var items: [String] = ["apple"]
}
var basket = Basket()
var borrowed = basket.items // an independent copy
borrowed.append("pear")
print(basket.items.count, borrowed.count)import java.util.ArrayList;
import java.util.List;
class Basket {
private final List<String> items = new ArrayList<>(List.of("apple"));
List<String> items() { return items; } // leaks the real list
List<String> safeItems() { return List.copyOf(items); }
}
class Main {
public static void main(String[] args) {
Basket basket = new Basket();
List<String> borrowed = basket.items();
borrowed.add("pear");
System.out.println(basket.items().size() + " " + borrowed.size());
}
}This is the single most common encapsulation bug on the JVM, and it is invisible if you are reading with Swift eyes. The three defences, in order of preference: store and return an immutable collection (
List.of, List.copyOf, which throw on mutation); return Collections.unmodifiableList(items), a live read-only view; or copy on the way out, as safeItems does. The same applies to arrays and to any mutable object you expose — including one you accept in a constructor, which should be copied on the way in.final is a much weaker let
let and final look like the same promise and are not. let on a value type makes the value itself immutable; final only stops the variable being pointed somewhere else.let items = ["apple"]
// items.append("pear") // uncomment: cannot use mutating member on a let
print(items.count)import java.util.ArrayList;
import java.util.List;
class Main {
public static void main(String[] args) {
final List<String> items = new ArrayList<>(List.of("apple"));
items.add("pear"); // legal: the BINDING is final, not the list
// items = new ArrayList<>(); // uncomment: cannot assign a final variable
System.out.println(items.size());
}
}So
final List still accepts add, exactly as let on a Swift class reference would still allow its properties to change. Immutability in Java is a property of the type, never of the binding: List.of(...) is immutable, new ArrayList<>(...) is not, and no keyword at the use site can change that. Write final anyway — it documents intent and the compiler needs it for capture in a lambda.There is no inout, and no pass-by-reference
Java is pass-by-value in every case — including for objects, where the reference is the value being copied. So a method can change what an object contains and can never change which object the caller's variable names.
func addTen(to number: inout Int) {
number += 10
}
var total = 5
addTen(to: &total)
print(total)class Counter {
int total;
Counter(int total) { this.total = total; }
}
class Main {
static void addTen(Counter counter) {
counter.total += 10; // mutating the object, not the variable
}
public static void main(String[] args) {
Counter counter = new Counter(5);
addTen(counter);
System.out.println(counter.total);
}
}That rules out
inout, swap functions, and any method that reassigns a caller's variable. The workarounds are to return the new value (much the best), to mutate a field of a passed object as above, or to pass a one-element array, which you will meet in old code and should not write. Note that the same rule makes the aliasing danger asymmetric: a method cannot repoint your variable, but it can gut the list your variable points at.Optionals Become null
Every reference can be null, and nothing says so
The loss to state plainly: in Swift the type distinguishes
String from String? and the compiler will not let you confuse them. In Java every reference type includes null, and there is no annotation in the language to say otherwise.func find(_ names: [String], _ target: String) -> String? {
names.first { $0 == target }
}
let found = find(["ada"], "bob")
print(found ?? "not found")
if let name = found {
print(name.count)
}import java.util.List;
class Main {
static String find(List<String> names, String target) {
for (String name : names) {
if (name.equals(target)) return name;
}
return null; // the signature does not say this
}
public static void main(String[] args) {
String found = find(List.of("ada"), "bob");
System.out.println(found != null ? found : "not found");
if (found != null) {
System.out.println(found.length());
}
}
}So the compiler cannot help, and the failure arrives as a
NullPointerException at the point of use rather than as an error at the point of the mistake. Since Java 14 the message names the expression that was null, which is a real improvement. Three things partly fill the gap and none of them is the language: Optional as a return type (the next row), nullability annotations that static analysers read (@Nullable, @NonNull — there are several competing packages), and Kotlin, which is the JVM language that did fix it.Optional<T> is a library class, not a type modifier
Optional gives you map, flatMap, filter and orElse, so the chaining reads much like Swift's. What it does not give you is enforcement.func find(_ names: [String], _ target: String) -> String? {
names.first { $0 == target }
}
let length = find(["ada"], "ada").map { $0.count } ?? 0
print(length)import java.util.List;
import java.util.Optional;
class Main {
static Optional<String> find(List<String> names, String target) {
return names.stream().filter(name -> name.equals(target)).findFirst();
}
public static void main(String[] args) {
int length = find(List.of("ada"), "ada").map(String::length).orElse(0);
System.out.println(length);
}
}An
Optional is itself a reference and can be null, which is the joke that writes itself. The official guidance is narrow and worth following: use it as a return type, and not for fields, parameters or collection elements — it costs an allocation, it does not serialise well, and Optional<List<T>> where an empty list would do is a smell. orElse is ??, orElseGet takes a lambda for an expensive default, and get() is the force-unwrap you should almost never write.Optional chaining, spelled out
There is no
?.. The two replacements are a chain of nested null checks, or wrapping the first value in an Optional and mapping — which is what this row shows, because it is the closer read.struct Address { let city: String? }
struct Customer { let address: Address? }
struct Order { let customer: Customer? }
let order = Order(customer: Customer(address: Address(city: nil)))
print(order.customer?.address?.city ?? "unknown")import java.util.Optional;
record Address(String city) {}
record Customer(Address address) {}
record Order(Customer customer) {}
class Main {
public static void main(String[] args) {
Order order = new Order(new Customer(new Address(null)));
String city = Optional.ofNullable(order.customer())
.map(Customer::address)
.map(Address::city)
.orElse("unknown");
System.out.println(city);
}
}Optional.ofNullable is the bridge from a possibly-null reference into the chain, and map short-circuits on empty exactly as ?. does. It costs an allocation per step, which matters in a hot loop and nowhere else. The alternative you will meet more often in real code is if (order != null && order.customer() != null && ...), which is why the nullability of every link ends up in your head rather than in the types.guard let becomes an early return
Two Swift conveniences vanish at once here:
guard let, and an integer conversion that returns an optional rather than throwing.func describe(_ raw: String?) -> String {
guard let raw, let number = Int(raw) else {
return "not a number"
}
return "twice \(number * 2)"
}
print(describe("21"), describe("x"), describe(nil))class Main {
static String describe(String raw) {
if (raw == null) return "not a number";
int number;
try {
number = Integer.parseInt(raw);
} catch (NumberFormatException error) {
return "not a number";
}
return "twice " + (number * 2);
}
public static void main(String[] args) {
System.out.println(describe("21") + " " + describe("x") + " " + describe(null));
}
}The early-return shape survives — it is just
if with a return, and Java has no else requirement to enforce it. The bindings do not: a variable bound inside a try block is not in scope after it, which is why number is declared first. And Integer.parseInt throws where Int(raw) returns nil, so the parse becomes exception handling — the running theme that Java uses exceptions where Swift uses optionals.What replaces a nil-returning initializer
A failable initializer has no Java counterpart: a constructor either completes or throws, and it can never hand back
null.struct Percentage {
let value: Int
init?(_ value: Int) {
guard (0...100).contains(value) else { return nil }
self.value = value
}
}
print(Percentage(50)?.value ?? -1)
print(Percentage(150)?.value ?? -1)import java.util.Optional;
record Percentage(int value) {
static Optional<Percentage> of(int value) {
return value >= 0 && value <= 100
? Optional.of(new Percentage(value))
: Optional.empty();
}
}
class Main {
public static void main(String[] args) {
System.out.println(Percentage.of(50).map(Percentage::value).orElse(-1));
System.out.println(Percentage.of(150).map(Percentage::value).orElse(-1));
}
}So the idiom is a static factory method — conventionally named
of, from or valueOf — with the constructor kept private or package-private. That has advantages Swift's initializers do not: a factory may return a cached instance or a subclass, and it may have a name that says what it does. A record can also validate in a compact constructor (record Percentage(int value) { Percentage { if (value < 0) throw new IllegalArgumentException(); } }) when throwing is the right answer instead of an empty Optional.Variables & Types
var, val and the missing let
Java's
var is Swift's type inference, not Swift's var: it says nothing about mutability. The immutable half is final, which goes before the type rather than replacing it.let name = "Ada"
var count = 0
count += 1
let typed: Double = 3
print(name, count, typed)class Main {
public static void main(String[] args) {
final String name = "Ada";
var count = 0; // inferred, and mutable
count += 1;
double typed = 3;
System.out.println(name + " " + count + " " + typed);
}
}So
final var name = "Ada" is legal and is the closest spelling of let. var works only for local variables with an initialiser — not for fields, parameters or return types — which keeps it from spreading the way inference does in Swift. And note the numeric literal: double typed = 3 compiles because Java widens int to double implicitly, where Swift would demand 3.0 or a conversion. Java's implicit numeric conversions are wider than Swift's in every direction that does not lose information.Primitives are not objects
Swift's
Int is a struct with methods and can be made optional. Java's int is eight bytes of nothing — no methods, no null, and it cannot go in a collection.let number: Int = 42
print(number.description)
print([1, 2, 3].reduce(0, +))
let optional: Int? = nil
print(optional ?? -1)import java.util.List;
class Main {
public static void main(String[] args) {
int number = 42;
System.out.println(Integer.toString(number));
System.out.println(List.of(1, 2, 3).stream().mapToInt(Integer::intValue).sum());
Integer boxed = null; // only the OBJECT type can be null
System.out.println(boxed != null ? boxed : -1);
}
}Each primitive has a matching boxed class (
int/Integer, double/Double, boolean/Boolean) and the compiler converts between them silently. Two traps follow. Unboxing a null Integer into an int throws a NullPointerException from a line that looks like arithmetic. And == on two Integers compares references — it happens to work for small values because −128 to 127 are cached, which makes the bug appear only in production. Compare boxed numbers with equals, always.Integer overflow wraps instead of trapping
Swift traps on overflow by default and makes you write
&+ to opt into wrapping. Java has the opposite default and no way to change it.let biggest = Int.max
print(biggest &+ 1) // &+ wraps, deliberately
// print(biggest + 1) // uncomment: this TRAPS at run time
print(Int.max.addingReportingOverflow(1).overflow)class Main {
public static void main(String[] args) {
long biggest = Long.MAX_VALUE;
System.out.println(biggest + 1); // wraps, silently
try {
Math.addExact(biggest, 1);
} catch (ArithmeticException error) {
System.out.println("true");
}
}
}So
Long.MAX_VALUE + 1 is Long.MIN_VALUE, with no exception and no warning — the same behaviour as Swift's &+, applied to every arithmetic expression in the language. The Math.*Exact family (addExact, multiplyExact, toIntExact) throws ArithmeticException instead and is what you want anywhere a number could be attacker-influenced. BigInteger is the arbitrary-precision escape hatch.There are no tuples
Returning two values needs a named type. Java has no tuple, no anonymous struct, and no multiple return.
func divide(_ numerator: Int, by denominator: Int) -> (quotient: Int, remainder: Int) {
(numerator / denominator, numerator % denominator)
}
let result = divide(17, by: 5)
print(result.quotient, result.remainder)record DivisionResult(int quotient, int remainder) {}
class Main {
static DivisionResult divide(int numerator, int denominator) {
return new DivisionResult(numerator / denominator, numerator % denominator);
}
public static void main(String[] args) {
DivisionResult result = divide(17, 5);
System.out.println(result.quotient() + " " + result.remainder());
}
}Before records this was genuinely painful — a whole class, or
Map.Entry, or an array of Object. A record makes it one line, and the result is better than a tuple: the components have names that survive into the caller and into the debugger, and the type is documentation. There is no destructuring at the call site, though a record pattern in a switch or instanceof gets close: if (result instanceof DivisionResult(int quotient, int remainder)).Strings
== on two strings is the wrong question
Swift's
== on a String compares text. Java's == on any reference compares identity, and a string is a reference.let first = "hello"
let second = "hel" + "lo"
print(first == second) // text, always — a String has no identity here
print(first == "hello")class Main {
public static void main(String[] args) {
String first = "hello";
String second = new String("hel") + "lo";
System.out.println(first == second); // identity — false!
System.out.println(first.equals(second)); // contents — true
}
}It is the first Java bug nearly everyone writes, and it is worse than a plain failure because it often works: identical string literals are interned into one object, so
"hello" == "hello" is true and the same comparison on a string that came from a file or a network is false. Always equals, and Objects.equals(a, b) when either might be null. The same rule applies to every type: == is identity unless you are comparing primitives.Characters, indices and counting
Java strings are indexed by integer, which is the convenience Swift withheld on purpose — and the reason it withheld it is still true.
let word = "naïve"
print(word.count)
print(word.uppercased())
print(word.prefix(3))
print(word.contains("ï"))class Main {
public static void main(String[] args) {
String word = "naïve";
System.out.println(word.length());
System.out.println(word.toUpperCase());
System.out.println(word.substring(0, 3));
System.out.println(word.contains("ï"));
}
}A Java
String is a sequence of UTF-16 code units, so length() counts units, not characters: "naïve" is 5 because ï fits in one unit, but any emoji outside the Basic Multilingual Plane counts as 2 and substring can split it in half. Swift's String.Index exists precisely to make that impossible. When correctness matters use codePointCount, codePoints(), or java.text.BreakIterator for grapheme clusters. Also note substring(start, end) takes an end index, not a length.Multi-line strings
Text blocks arrived in Java 15 and behave almost exactly like Swift's multi-line string literals, including stripping the common indentation.
let report = """
Sales report
Q1: 100
"""
print(report)class Main {
public static void main(String[] args) {
String report = """
Sales report
Q1: 100
""";
System.out.print(report);
}
}The margin is set by the least-indented line or the closing delimiter, whichever is further left — the same rule Swift uses for its closing
""". One difference to watch: a Java text block keeps the trailing newline before the closing delimiter, which is why this example uses print rather than println; put the closing """ on the same line as the last character to drop it, or end the line with \. There is no interpolation, so formatted(...) is the usual companion.Splitting and joining
Both operations exist with familiar names, and the argument to
split is the thing to notice.let line = "alice,bob,carol"
let people = line.split(separator: ",").map(String.init)
print(people.count)
print(people.joined(separator: " | "))import java.util.Arrays;
import java.util.List;
class Main {
public static void main(String[] args) {
String line = "alice,bob,carol";
List<String> people = Arrays.asList(line.split(","));
System.out.println(people.size());
System.out.println(String.join(" | ", people));
}
}Java's
split takes a regular expression, not a literal separator — so splitting on "." or "|" silently gives you nothing useful until you escape it (split("\\.")) or use Pattern.quote. It also drops trailing empty strings unless you pass a negative limit. String.join puts the separator first, the reverse of Swift's joined(separator:), and Collectors.joining is the streams version with optional prefix and suffix.Collections
Array becomes List, and it is a reference
Everything from the value-semantics section applies with full force to collections, which is where it will actually cost you a bug.
var numbers = [1, 2, 3]
numbers.append(4)
print(numbers.count, numbers[2])
let copy = numbers // an independent copy
numbers.append(5)
print(copy.count)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));
numbers.add(4);
System.out.println(numbers.size() + " " + numbers.get(2));
List<Integer> copy = numbers; // the SAME list
numbers.add(5);
System.out.println(copy.size());
}
}Swift prints
4 for the copy; Java prints 5, because copy is the same list. A real copy is new ArrayList<>(numbers) and is shallow. Note the interface-versus-implementation habit: declare the variable as List and construct an ArrayList, so callers depend on the contract rather than the class. List.of(...) builds an immutable list and is the right default when nothing needs to change; it throws UnsupportedOperationException on add, which is a run-time answer to a question Swift settles at compile time.Dictionary becomes Map
The mapping type and its three everyday operations, with one important difference in what a missing key gives you.
var ages = ["ada": 36, "grace": 45]
ages["carol"] = 35
print(ages["ada"] ?? 0)
print(ages["nobody"] ?? 0)
for name in ages.keys.sorted() {
print(name, ages[name]!)
}import java.util.Map;
import java.util.TreeMap;
class Main {
public static void main(String[] args) {
Map<String, Integer> ages = new TreeMap<>(Map.of("ada", 36, "grace", 45));
ages.put("carol", 35);
System.out.println(ages.getOrDefault("ada", 0));
System.out.println(ages.getOrDefault("nobody", 0));
for (var entry : ages.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
}Swift returns an
Optional from a subscript; Java's get returns null, so getOrDefault is the safer everyday call and containsKey answers the membership question. The implementations differ in ordering, which Swift's single Dictionary hides: HashMap has no order at all, LinkedHashMap keeps insertion order, and TreeMap — used here — keeps keys sorted, so the loop needs no explicit sort. Also useful: computeIfAbsent, which builds a value in place and replaces the read-check-write dance.map and filter become a Stream
The operations have the same names and the pipeline needs an explicit start and end:
stream() opens it, and a terminal operation closes it.let numbers = [1, 2, 3, 4, 5, 6]
let total = numbers
.filter { $0 % 2 == 0 }
.map { $0 * $0 }
.reduce(0, +)
print(total)import java.util.List;
class Main {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
int total = numbers.stream()
.filter(number -> number % 2 == 0)
.map(number -> number * number)
.reduce(0, Integer::sum);
System.out.println(total);
}
}A stream is lazy and single-use, which makes it closer to Swift's
lazy view than to a plain array chain — reusing one throws IllegalStateException. Collecting back to a list is .toList() (Java 16+) or .collect(Collectors.toList()). For numbers, mapToInt gives you an IntStream with a real sum() and no boxing, which is worth reaching for. The Swift equivalents that have no stream counterpart are the free-standing operators: there is no zip and no enumerated() in the standard library.Sorting by a key
Swift takes a two-argument predicate; Java takes a
Comparator, and builds it from a key extractor rather than from a comparison.struct Person { let name: String; let age: Int }
let people = [Person(name: "Ada", age: 36), Person(name: "Bob", age: 25)]
for person in people.sorted(by: { $0.age < $1.age }) {
print(person.name, person.age)
}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 = List.of(new Person("Ada", 36), new Person("Bob", 25));
for (Person person : people.stream().sorted(Comparator.comparingInt(Person::age)).toList()) {
System.out.println(person.name() + " " + person.age());
}
}
}Comparator.comparing(Person::name) is the general form, with comparingInt/comparingDouble avoiding boxing, .thenComparing(...) chaining a tie-breaker, and .reversed() flipping it — a small combinator language that ends up more readable than a hand-written closure once there are two keys. List.sort mutates in place and needs a mutable list; the stream version above copies. Both are stable.Sets, and what makes two things equal
Both languages key their hashed collections on an equality-and-hash pair, and both generate it for you — for a Swift
struct that declares Hashable, and for a Java record.struct Point: Hashable {
let x: Int
let y: Int
}
var seen: Set<Point> = []
seen.insert(Point(x: 1, y: 2))
seen.insert(Point(x: 1, y: 2))
print(seen.count)import java.util.HashSet;
import java.util.Set;
record Point(int x, int y) {}
class Main {
public static void main(String[] args) {
Set<Point> seen = new HashSet<>();
seen.add(new Point(1, 2));
seen.add(new Point(1, 2));
System.out.println(seen.size());
}
}For a plain Java class you must override
equals and hashCode together, and the contract is unforgiving: equal objects must have equal hash codes, or the object goes into a HashMap and is never found again. That is the strongest practical argument for making value-shaped types records. One more rule with no Swift counterpart: mutating a field that hashCode reads, after the object is in a set, loses it — which is another reason those types should be immutable.Control Flow
Loops, ranges and the missing enumerated()
Java has the C-style
for that ranges save you from writing, plus an enhanced for that is Swift's for x in.for index in 0..<3 {
print(index)
}
let colours = ["red", "green"]
for (index, colour) in colours.enumerated() {
print(index, colour)
}import java.util.List;
class Main {
public static void main(String[] args) {
for (int index = 0; index < 3; index++) {
System.out.println(index);
}
List<String> colours = List.of("red", "green");
for (int index = 0; index < colours.size(); index++) {
System.out.println(index + " " + colours.get(index));
}
}
}There is no range type and no
enumerated(), so an index-and-value loop is the counted form — IntStream.range(0, list.size()) exists but reads worse for this. break and continue work as expected, and Java adds labelled versions (outer: for (...) { ... continue outer; }) that let you jump out of nested loops, which Swift also has. What Java does not have is where clauses on a loop, or for case let.switch is an expression, and no longer falls through
The modern arrow form of
switch, added in Java 14, is much closer to Swift than the old colon-and-break form you will still meet in older code.func describe(_ code: Int) -> String {
switch code {
case 200, 201: return "ok"
case 400...499: return "client error"
default: return "something else"
}
}
print(describe(201), describe(404), describe(500))class Main {
static String describe(int code) {
return switch (code) {
case 200, 201 -> "ok";
case 400, 404, 499 -> "client error";
default -> "something else";
};
}
public static void main(String[] args) {
System.out.println(describe(201) + " " + describe(404) + " " + describe(500));
}
}With
-> there is no fall-through and no break, several labels can share an arm, and the whole thing is an expression that produces a value. What is missing is ranges: case 400...499 has no counterpart, so a range test becomes an if chain or a switch (true)-style guard. Swift's where clause does exist, as case Integer number when number > 100 ->, but only in a pattern arm — the enums section shows it.defer becomes try-with-resources or finally
There is no
defer. The two replacements are finally, which runs a block whatever happens, and try-with-resources, which closes anything implementing AutoCloseable — including on the way out through an exception.func process() {
print("open")
defer { print("close") }
print("work")
}
process()
print("after")class Resource implements AutoCloseable {
Resource() { System.out.println("open"); }
public void close() { System.out.println("close"); }
}
class Main {
public static void main(String[] args) {
try (Resource resource = new Resource()) {
System.out.println("work");
}
System.out.println("after");
}
}Try-with-resources is the better analogue because it ties the cleanup to the resource rather than to the line you happened to write it on, and it closes several resources in reverse order of acquisition, exactly as stacked
defers unwind. Where defer is doing something that is not a resource — restoring a flag, logging on exit — finally is the direct translation. Neither is a substitute for deinit, which the memory section covers.Functions & Closures
Argument labels and default values are gone
Java has neither default parameter values nor argument labels, and this row shows what fills the gap: a family of overloads that delegate to the fullest one.
func connect(to host: String, port: Int = 5432, timeout: Int = 30) {
print("\(host):\(port) timeout=\(timeout)")
}
connect(to: "db.example.com")
connect(to: "db.example.com", timeout: 5)class Main {
static void connect(String host) { connect(host, 5432, 30); }
static void connect(String host, int timeout) { connect(host, 5432, timeout); }
static void connect(String host, int port, int timeout) {
System.out.println(host + ":" + port + " timeout=" + timeout);
}
public static void main(String[] args) {
connect("db.example.com");
connect("db.example.com", 5);
}
}That is the standard idiom and it has real costs — the combinations multiply, and two overloads that differ only in an
int cannot express which int a caller meant, as the second call above quietly demonstrates. For more than about three optional values the idiom shifts to a builder, or to a parameter object (a record). What Java does have is varargs: void log(String... parts) accepts any number and receives an array.Closures become lambdas, and capture is final
A Java lambda may only capture a variable that is effectively final — assigned once and never reassigned. So the mutable-counter closure, which is the first thing anyone tries, does not compile.
func makeCounter() -> () -> Int {
var count = 0
return {
count += 1
return count
}
}
let counter = makeCounter()
print(counter(), counter(), counter())import java.util.function.IntSupplier;
import java.util.concurrent.atomic.AtomicInteger;
class Main {
static IntSupplier makeCounter() {
AtomicInteger count = new AtomicInteger(0);
return () -> count.incrementAndGet();
}
public static void main(String[] args) {
IntSupplier counter = makeCounter();
System.out.println(counter.getAsInt() + " " + counter.getAsInt() + " " + counter.getAsInt());
}
}The workaround is to capture a mutable object rather than a mutable variable: an
AtomicInteger, a one-element array, or a field. This is the same restriction that makes Java's lambdas cheap — captured values are copied into the lambda object — and the reason there is no [weak self] to write: a lambda that captures this keeps the enclosing object alive, and the garbage collector deals with the cycle.Function types are interfaces
Java has no function type. A lambda is an instance of a functional interface — any interface with exactly one abstract method — and the interface name is the type you write.
let double: (Int) -> Int = { $0 * 2 }
let describe: (Int) -> String = { "value \($0)" }
func apply(_ transform: (Int) -> Int, to value: Int) -> Int {
transform(value)
}
print(apply(double, to: 21), describe(7))import java.util.function.Function;
import java.util.function.IntUnaryOperator;
class Main {
static int apply(IntUnaryOperator transform, int value) {
return transform.applyAsInt(value);
}
public static void main(String[] args) {
IntUnaryOperator doubler = value -> value * 2;
Function<Integer, String> describe = value -> "value " + value;
System.out.println(apply(doubler, 21) + " " + describe.apply(7));
}
}So
(Int) -> Int has no single spelling: it is Function<Integer, Integer> if you accept boxing, IntUnaryOperator if you do not, or an interface of your own. The java.util.function package holds forty-odd of these, and the naming is systematic once you see it: Function takes one and returns one, BiFunction two, Supplier none, Consumer returns nothing, Predicate returns boolean, and an Int/Long/Double prefix means the primitive version. The method name differs per interface — apply, get, accept, test — which is the part that feels arbitrary.Method references, and no trailing closures
The
:: operator names an existing method as a lambda, which is Java's equivalent of passing String.uppercased directly.let names = ["ada", "grace"]
print(names.map { $0.uppercased() }.joined(separator: ","))
print(names.map(\.count))import java.util.List;
import java.util.stream.Collectors;
class Main {
public static void main(String[] args) {
List<String> names = List.of("ada", "grace");
System.out.println(names.stream().map(String::toUpperCase).collect(Collectors.joining(",")));
System.out.println(names.stream().map(String::length).toList());
}
}There are four forms and they are worth recognising:
Type::staticMethod, instance::method, Type::instanceMethod (where the first argument becomes the receiver — that is what String::toUpperCase is doing), and Type::new for a constructor. What Java does not have is trailing-closure syntax, so the lambda always sits inside the parentheses, and there is no shorthand for the parameter: $0 must be given a name.There are no extensions
Java has no way to add a method to a type you do not own. A helper is a static method in a utility class, called with the value as its first argument.
extension String {
var shouted: String { uppercased() + "!" }
}
print("hello".shouted)class StringHelpers {
static String shouted(String text) {
return text.toUpperCase() + "!";
}
}
class Main {
public static void main(String[] args) {
System.out.println(StringHelpers.shouted("hello"));
}
}This is one of the genuinely bigger losses coming from Swift, and it explains a great deal of Java's shape:
Collections.sort(list), Arrays.asList(array), Objects.requireNonNull(value) and the whole StringUtils genre exist because the methods could not live on the types. It also means retroactive conformance is impossible — you cannot make a third-party class implement your interface, which the protocols section returns to. Kotlin's extension functions are the JVM answer, and they compile to exactly the static methods above.Classes & Initialization
A class and its constructor
The bones are the same: a constructor named after the class, fields, methods, and
this for self.class Account {
let owner: String
private(set) var balance: Int
init(owner: String, balance: Int = 0) {
self.owner = owner
self.balance = balance
}
func deposit(_ amount: Int) {
balance += amount
}
}
let account = Account(owner: "Ada")
account.deposit(100)
print(account.owner, account.balance)class Account {
private final String owner;
private int balance;
Account(String owner) { this(owner, 0); }
Account(String owner, int balance) {
this.owner = owner;
this.balance = balance;
}
String owner() { return owner; }
int balance() { return balance; }
void deposit(int amount) { balance += amount; }
}
class Main {
public static void main(String[] args) {
Account account = new Account("Ada");
account.deposit(100);
System.out.println(account.owner() + " " + account.balance());
}
}What Swift has and Java does not: default parameter values (hence the delegating constructor, calling the other with
this(...)), and private(set), which becomes a private field plus a public accessor. What Java has and Swift does not: a compiler that insists every final field is definitely assigned by the end of every constructor, which is the same guarantee Swift's two-phase initialisation gives, checked differently. There is no convenience init distinction and no required init.Inheritance: open by default, and override is required
The defaults are inverted. A Swift class is
final-ish outside its module unless marked open; a Java class is inheritable and every method is virtual unless marked final.class Animal {
func speak() -> String { "..." }
}
class Dog: Animal {
override func speak() -> String { "Woof" }
}
for animal in [Animal(), Dog()] {
print(animal.speak())
}class Animal {
String speak() { return "..."; }
}
class Dog extends Animal {
@Override
String speak() { return "Woof"; }
}
class Main {
public static void main(String[] args) {
for (Animal animal : new Animal[] { new Animal(), new Dog() }) {
System.out.println(animal.speak());
}
}
}So the discipline moves to you: mark a class
final when it is not designed for extension, which is most of them. @Override is an annotation rather than a keyword, and it is optional — omit it and a misspelled method name silently becomes a new method instead of an override, which is exactly the bug Swift's mandatory override prevents. Write it every time; every IDE and linter will insist. super.speak() works as expected, and there is no multiple inheritance.Static members and nested types
Type-level state and nested types both exist, and one detail of nesting has no Swift equivalent at all.
class Counter {
static var created = 0
init() { Counter.created += 1 }
struct Snapshot {
let total: Int
}
}
_ = Counter()
_ = Counter()
print(Counter.created)
print(Counter.Snapshot(total: Counter.created).total)class Counter {
static int created = 0;
Counter() { created += 1; }
record Snapshot(int total) {}
}
class Main {
public static void main(String[] args) {
new Counter();
new Counter();
System.out.println(Counter.created);
System.out.println(new Counter.Snapshot(Counter.created).total());
}
}A Java nested class is inner — holding a hidden reference to the enclosing instance — unless you mark it
static. Records, enums and interfaces nested in a class are implicitly static; a plain class Snapshot would not be, and that hidden reference is a classic memory leak and the reason new Outer.Inner() needs an outer instance. Always write static on a nested class that does not need its enclosing object. Java's static members are also not inherited-and-overridable the way Swift's class var is.Computed properties are just methods
Java has no property syntax. A computed property becomes a method, and a settable one becomes a getter/setter pair, called with parentheses at every use site.
struct Rectangle {
var width: Double
var height: Double
var area: Double { width * height }
var scaled: Double {
get { width * 2 }
set { width = newValue / 2 }
}
}
var rectangle = Rectangle(width: 3, height: 4)
print(rectangle.area)
rectangle.scaled = 10
print(rectangle.width)class Rectangle {
private double width;
private final double height;
Rectangle(double width, double height) { this.width = width; this.height = height; }
double area() { return width * height; }
double getScaled() { return width * 2; }
void setScaled(double value) { this.width = value / 2; }
double width() { return width; }
}
class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(3, 4);
System.out.println(rectangle.area());
rectangle.setScaled(10);
System.out.println(rectangle.width());
}
}The
getX/setX naming is the JavaBeans convention, and it is not merely cosmetic — frameworks (Jackson, JPA, Spring) discover properties by that naming, so departing from it changes behaviour. Records use the shorter accessor form (point.x()) instead, which is now the preferred style for new value-shaped types. There is no willSet/didSet, no lazy var (write the null-check yourself, or use a holder), and no property wrappers.Protocols & Interfaces
A protocol becomes an interface
The mapping is direct — an interface declares methods a type must provide — and
default methods cover much of what a protocol extension does.protocol Shape {
var area: Double { get }
func describe() -> String
}
struct Square: Shape {
let side: Double
var area: Double { side * side }
func describe() -> String { "square of area \(area)" }
}
let shape: Shape = Square(side: 3)
print(shape.describe())interface Shape {
double area();
default String describe() { return "shape of area " + area(); }
}
record Square(double side) implements Shape {
public double area() { return side * side; }
public String describe() { return "square of area " + area(); }
}
class Main {
public static void main(String[] args) {
Shape shape = new Square(3);
System.out.println(shape.describe());
}
}Three differences to hold on to. Conformance must be declared at the type's definition, so you cannot make someone else's class implement your interface: retroactive conformance is impossible, and that is a real loss. An interface may not hold stored state, so a protocol extension that adds a property has no counterpart. And implementing methods must be
public — every interface member is public, which is why Square's methods carry the modifier while the record's own accessors do not need it stated.Protocol extensions become default methods
A
default method supplies an implementation in the interface itself, which is what a protocol extension does for the common case.protocol Greeter {
var name: String { get }
}
extension Greeter {
func greet() -> String { "hello, \(name)" }
}
struct Person: Greeter {
let name: String
}
print(Person(name: "Ada").greet())interface Greeter {
String name();
default String greet() { return "hello, " + name(); }
}
record Person(String name) implements Greeter {}
class Main {
public static void main(String[] args) {
System.out.println(new Person("Ada").greet());
}
}Where they part company is dispatch. A method declared only in a Swift protocol extension is dispatched statically, so a conforming type's own version is ignored when the value is held as the protocol — the notorious gotcha. A Java
default method is always virtual: an implementing class's override wins, whatever the static type. So Java is simpler here, and the Swift habit of checking whether a method is in the protocol requirement list has nothing to check. Interfaces may also hold static methods and public static final constants.Associated types become type parameters
Java has no associated types: a generic interface takes its parameter in angle brackets, and the implementing type fills it in.
protocol Container {
associatedtype Item
func first() -> Item?
var count: Int { get }
}
struct Basket: Container {
let items: [String]
func first() -> String? { items.first }
var count: Int { items.count }
}
let basket = Basket(items: ["apple", "pear"])
print(basket.first() ?? "-", basket.count)import java.util.List;
import java.util.Optional;
interface Container<Item> {
Optional<Item> first();
int count();
}
record Basket(List<String> items) implements Container<String> {
public Optional<String> first() { return items.stream().findFirst(); }
public int count() { return items.size(); }
}
class Main {
public static void main(String[] args) {
Basket basket = new Basket(List.of("apple", "pear"));
System.out.println(basket.first().orElse("-") + " " + basket.count());
}
}For this shape the translation is exact and arguably simpler — no
where clauses, no Self requirements, and a Container can be used as a variable type directly. What you lose is everything some and any were invented for. There is no opaque return type, so a method cannot promise "some Container without saying which"; and there is no distinction between a protocol used as a constraint and as an existential, because Container<String> is always usable as a type. Java's cost shows up instead in the wildcard syntax of the next row.Equatable and Comparable, spelled out
Ordering is one method returning a negative number, zero or a positive one — there is no operator to overload, because Java has no operator overloading at all.
struct Version: Comparable {
let major: Int
let minor: Int
static func < (left: Version, right: Version) -> Bool {
(left.major, left.minor) < (right.major, right.minor)
}
}
print(Version(major: 1, minor: 2) < Version(major: 1, minor: 10))
print(Version(major: 1, minor: 2) == Version(major: 1, minor: 2))record Version(int major, int minor) implements Comparable<Version> {
public int compareTo(Version other) {
return major != other.major
? Integer.compare(major, other.major)
: Integer.compare(minor, other.minor);
}
}
class Main {
public static void main(String[] args) {
System.out.println(new Version(1, 2).compareTo(new Version(1, 10)) < 0);
System.out.println(new Version(1, 2).equals(new Version(1, 2)));
}
}So
<, > and + can never be given a meaning for your type, and every comparison reads as compareTo(...) < 0. Comparable is the natural ordering built into the type; a Comparator is an ordering supplied from outside, which is the more flexible and more common tool. The contract that matters: compareTo returning zero should agree with equals, or a TreeSet and a HashSet will disagree about what the same collection contains.any Protocol, and the missing some
Swift makes you choose between
some Shape — one concrete type the caller does not learn — and any Shape, a box that can hold different ones. Java has only the second, and does not make you write it.protocol Shape {
func area() -> Double
}
struct Square: Shape {
let side: Double
func area() -> Double { side * side }
}
func makeSquare() -> some Shape { Square(side: 2) }
let shapes: [any Shape] = [Square(side: 2), Square(side: 3)]
print(makeSquare().area(), shapes.map { $0.area() })import java.util.List;
interface Shape {
double area();
}
record Square(double side) implements Shape {
public double area() { return side * side; }
}
class Main {
static Shape makeSquare() { return new Square(2); } // no 'some'; this is always 'any'
public static void main(String[] args) {
List<Shape> shapes = List.of(new Square(2), new Square(3));
System.out.println(makeSquare().area() + " " + shapes.stream().map(Shape::area).toList());
}
}Every interface-typed reference is an existential, dispatched through a method table, and the compiler never specialises. That removes a whole category of decision and a whole category of performance tuning: there is no way to say "this returns one concrete type" and no
@inlinable-style escape. In exchange, the errors Swift raises about protocols with Self or associated-type requirements not being usable as types simply do not exist — a generic interface can always be a variable type, at the cost of a wildcard when the parameter is unknown.Enums, Sealed Types & Patterns
A plain enum, with methods
A Java enum is a class whose instances are a fixed, named set, so it can hold fields, a constructor and methods — much more than the C-style enums you may be bracing for.
enum Status: String {
case active = "active"
case retired = "retired"
var label: String {
switch self {
case .active: return "still here"
case .retired: return "gone"
}
}
}
print(Status.active.label, Status.retired.rawValue)
print(Status(rawValue: "active") == .active)enum Status {
ACTIVE("active"), RETIRED("retired");
private final String raw;
Status(String raw) { this.raw = raw; }
String raw() { return raw; }
String label() {
return switch (this) {
case ACTIVE -> "still here";
case RETIRED -> "gone";
};
}
}
class Main {
public static void main(String[] args) {
System.out.println(Status.ACTIVE.label() + " " + Status.RETIRED.raw());
System.out.println(Status.valueOf("ACTIVE") == Status.ACTIVE);
}
}Because the cases are singletons,
== is the correct comparison here — the one place in Java where reference equality is idiomatic. A raw value is a field you declare yourself; valueOf looks a case up by its name and throws when there is no match, so the closest thing to Swift's failable init(rawValue:) is a static lookup you write. A switch over all cases of an enum needs no default, and adding a case later turns every such switch into a compile error — which is the exhaustiveness Swift gives you.Associated values become sealed interfaces and records
This is the row where Java caught up. A
sealed interface names every type allowed to implement it, so the compiler knows the full set — which is exactly what an enum with associated values gives you.enum Shape {
case circle(radius: Double)
case rectangle(width: Double, height: Double)
}
func area(_ shape: Shape) -> Double {
switch shape {
case .circle(let radius): return 3.14 * radius * radius
case .rectangle(let width, let height): return width * height
}
}
print(area(.circle(radius: 1)), area(.rectangle(width: 2, height: 3)))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 -> 3.14 * circle.radius() * circle.radius();
case Rectangle rectangle -> rectangle.width() * rectangle.height();
};
}
public static void main(String[] args) {
System.out.println(area(new Circle(1)) + " " + area(new Rectangle(2, 3)));
}
}The
switch above needs no default, and adding a third permitted type makes it fail to compile until you handle it. That is real exhaustiveness, not a convention. The differences from Swift are in the ergonomics rather than the guarantee: each case is a separate top-level type rather than a case of one, so you write three declarations instead of one enum; and there is no dot-shorthand, so .circle(radius: 1) becomes new Circle(1). Sealed types can also permit classes, and the permitted types must live in the same package or module.Binding the payload: record patterns
A record pattern destructures in the
case label, binding the components to names — the direct counterpart of case .circle(let radius).enum Shape {
case circle(radius: Double)
case rectangle(width: Double, height: Double)
}
func describe(_ shape: Shape) -> String {
switch shape {
case .circle(let radius) where radius > 10: return "a big circle"
case .circle(let radius): return "a circle of \(radius)"
case .rectangle(let width, let height) where width == height: return "a square of \(width)"
case .rectangle: return "a rectangle"
}
}
print(describe(.circle(radius: 1)))
print(describe(.rectangle(width: 2, height: 2)))sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
class Main {
static String describe(Shape shape) {
return switch (shape) {
case Circle(double radius) when radius > 10 -> "a big circle";
case Circle(double radius) -> "a circle of " + radius;
case Rectangle(double width, double height) when width == height -> "a square of " + width;
case Rectangle rectangle -> "a rectangle";
};
}
public static void main(String[] args) {
System.out.println(describe(new Circle(1)));
System.out.println(describe(new Rectangle(2, 2)));
}
}Even the guard survives, spelled
when rather than where. Patterns nest, so case Order(Customer(String name), _) reaches two levels down. Two things Swift still has that Java does not: if case as a standalone statement (Java's instanceof pattern covers the common case — if (shape instanceof Circle(double radius))), and value patterns over ranges. Note that a bare type pattern (case Rectangle rectangle) and a record pattern can be mixed freely.is and as? become instanceof
Pattern matching for
instanceof (Java 16) folds the test and the cast into one, which is if let x = value as? T with the words in a different order.let values: [Any] = ["hello", 42, 3.5]
for value in values {
if let text = value as? String {
print("string of \(text.count)")
} else if value is Int {
print("an integer")
} else {
print("something else")
}
}import java.util.List;
class Main {
public static void main(String[] args) {
List<Object> values = List.of("hello", 42, 3.5);
for (Object value : values) {
if (value instanceof String text) {
System.out.println("string of " + text.length());
} else if (value instanceof Integer) {
System.out.println("an integer");
} else {
System.out.println("something else");
}
}
}
}The bound variable is in scope wherever the test is known to have succeeded, including the rest of an
&& chain and the body of an early return's opposite branch — the compiler tracks it by flow. Without the pattern you get the old form, ((String) value).length(), which throws ClassCastException where as! would trap. There is no as? expression that yields an optional, so a cast is always a test followed by a use.Generics & Erasure
A generic function
The shape is familiar; the type parameter simply moves to before the return type.
func firstOrDefault<Element>(_ items: [Element], _ fallback: Element) -> Element {
items.first ?? fallback
}
print(firstOrDefault([1, 2], 0))
print(firstOrDefault([String](), "none"))import java.util.List;
class Main {
static <Element> Element firstOrDefault(List<Element> items, Element fallback) {
return items.isEmpty() ? fallback : items.get(0);
}
public static void main(String[] args) {
System.out.println(firstOrDefault(List.of(1, 2), 0));
System.out.println(firstOrDefault(List.of(), "none"));
}
}Constraints are spelled
<Element extends Comparable<Element>> — extends for both classes and interfaces, with & to require several. There is no where clause of Swift's expressiveness and no way to constrain an associated type, because there are none. Inference at the call site works much as Swift's does, and the explicit form is Main.<String>firstOrDefault(...), which you will rarely need and never enjoy.Generics are erased at run time
This is the deepest difference in the generics story: Java's type arguments exist for the compiler and are erased before the program runs.
let numbers: [Int] = [1, 2, 3]
let words: [String] = ["a"]
print(type(of: numbers) == type(of: words))
print(type(of: numbers))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"));
System.out.println(numbers.getClass() == words.getClass());
System.out.println(numbers.getClass().getSimpleName());
}
}So a
List<String> and a List<Integer> are the same class at run time — both print ArrayList, and comparing the classes gives true, where Swift reports two different types. (The example constructs ArrayLists deliberately: List.of returns size-specialised classes, so a one-element list and a three-element list differ for a reason that has nothing to do with generics.) and the consequences are everywhere. You cannot write new Element[10], ask value instanceof List<String>, overload two methods that differ only by type argument, or catch a generic exception type. Where Swift can specialise a generic function per type, Java compiles one version that works on Object and inserts casts — which is also why a List<Integer> boxes every element. The escape hatch when a method genuinely needs the type is to pass a Class<Element> token as an argument.Wildcards, and why they exist
A
List<Integer> is not a List<Number> in Java — generics are invariant — so a method that accepts "a list of any kind of number" needs the wildcard syntax.func total<Number: BinaryFloatingPoint>(_ numbers: [Number]) -> Double {
numbers.reduce(0) { $0 + Double($1) }
}
print(total([1.0, 2.0, 3.0]))
print(total([1.5, 2.5]))import java.util.List;
class Main {
static double total(List<? extends Number> numbers) {
double sum = 0;
for (Number number : numbers) sum += number.doubleValue();
return sum;
}
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)));
}
}? extends Number means "some unknown subtype of Number": you may read Numbers out and may not add anything in, because the compiler does not know which subtype the list actually holds. ? super Integer is the mirror image, for a list you will only write to. The mnemonic is PECS — Producer extends, Consumer super. Swift needs none of this for the example above, because a generic parameter accepts any concrete element type directly and the compiler specialises per type. The wildcard exists because Java's single erased implementation has to be told, at the signature, which direction the unknown type may flow. Recognising why it is there is most of the work.throws Meets Checked Exceptions
throws, on both sides
This is the genuine convergence on the page, and it is worth pausing on. Swift's
throws and Java's checked exceptions are the same idea reached thirty years apart: a function declares that it may fail, and the compiler forces every caller to handle it or declare it too.enum ConfigError: Error {
case missing(key: String)
}
func lookup(_ key: String, in settings: [String: String]) throws -> String {
guard let value = settings[key] else {
throw ConfigError.missing(key: key)
}
return value
}
do {
print(try lookup("host", in: ["host": "db"]))
print(try lookup("port", in: ["host": "db"]))
} catch ConfigError.missing(let key) {
print("missing \(key)")
} catch {
print("other error")
}import java.util.Map;
class ConfigException extends Exception {
private final String key;
ConfigException(String key) { super("missing " + key); this.key = key; }
String key() { return key; }
}
class Main {
static String lookup(String key, Map<String, String> settings) throws ConfigException {
String value = settings.get(key);
if (value == null) throw new ConfigException(key);
return value;
}
public static void main(String[] args) {
Map<String, String> settings = Map.of("host", "db");
try {
System.out.println(lookup("host", settings));
System.out.println(lookup("port", settings));
} catch (ConfigException error) {
System.out.println("missing " + error.key());
}
}
}So
throws means almost exactly what you expect, do/catch is try/catch, and the compiler error for an unhandled failure is the same complaint in different words. The one syntactic difference is that Java has no try keyword at the call site — the call looks ordinary, and the compiler tracks what may throw — where Swift makes every fallible call visible. Java is also more specific: throws ConfigException, IOException lists the types, and a catch clause selects by type rather than by pattern.The other half: unchecked exceptions
Java has a second category that the
throws comparison hides. Anything extending RuntimeException — or Error — is unchecked: no declaration, no compiler enforcement, and it can arrive from any line.func divide(_ numerator: Int, by denominator: Int) -> Int {
// A precondition failure is not catchable — it terminates the process.
precondition(denominator != 0, "denominator must not be zero")
return numerator / denominator
}
print(divide(10, by: 2))class Main {
static int divide(int numerator, int denominator) {
if (denominator == 0) throw new IllegalArgumentException("denominator must not be zero");
return numerator / denominator;
}
public static void main(String[] args) {
System.out.println(divide(10, 2));
try {
divide(10, 0);
} catch (IllegalArgumentException error) {
System.out.println("caught: " + error.getMessage());
}
}
}That is where
NullPointerException, IllegalArgumentException, IndexOutOfBoundsException and ArithmeticException live, and it is closest to Swift's fatalError/precondition — with the crucial difference that it is catchable and routinely is, at a request boundary or a thread's top level. The convention is: checked for what a caller can reasonably recover from, unchecked for programming errors. Opinion inside the Java world is genuinely divided about checked exceptions, and much modern code (and Kotlin, and most frameworks) uses unchecked ones throughout.try? and try! have no counterpart
There is no operator that turns a thrown error into an empty optional, and none that asserts a call cannot fail.
enum ParseError: Error { case bad }
func parse(_ text: String) throws -> Int {
guard let number = Int(text) else { throw ParseError.bad }
return number
}
print((try? parse("21")) ?? -1)
print((try? parse("x")) ?? -1)import java.util.Optional;
class Main {
static Optional<Integer> parse(String text) {
try {
return Optional.of(Integer.parseInt(text));
} catch (NumberFormatException error) {
return Optional.empty();
}
}
public static void main(String[] args) {
System.out.println(parse("21").orElse(-1));
System.out.println(parse("x").orElse(-1));
}
}Converting an exception into an
Optional is a try/catch written out, usually wrapped in a small helper the way parse is here — and doing it in a lambda inside a stream is genuinely awkward, because a checked exception cannot escape a Function. The nearest thing to try! is catching and rethrowing as an unchecked exception, which is what throw new RuntimeException(error) means when you see it, and what Optional.get() amounts to.Result has no standard equivalent
There is no
Result in the standard library. Where you want failure as a value rather than as an exception, a sealed interface with two records is the idiomatic modern shape.enum LoadError: Error {
case notFound
var reason: String { "not found" }
}
func load(_ id: Int) -> Result<String, LoadError> {
id == 1 ? .success("record one") : .failure(.notFound)
}
switch load(2) {
case .success(let value): print(value)
case .failure(let error): print("failed: \(error.reason)")
}sealed interface Load permits Loaded, Failed {}
record Loaded(String value) implements Load {}
record Failed(String reason) implements Load {}
class Main {
static Load load(int id) {
return id == 1 ? new Loaded("record one") : new Failed("not found");
}
public static void main(String[] args) {
String message = switch (load(2)) {
case Loaded(String value) -> value;
case Failed(String reason) -> "failed: " + reason;
};
System.out.println(message);
}
}It is more verbose than
Result and gives the same exhaustiveness, plus the freedom to name the cases after the domain rather than "success" and "failure". What it does not give you is the combinator vocabulary — no map, flatMap or get() unless you write them. This pattern is common in newer codebases and in functional-leaning libraries; a great deal of Java simply throws instead, and that is not wrong.ARC Against a Tracing Collector
There is no deinit
ARC releases an object the moment its last reference goes away, so
deinit runs at a knowable time. A tracing collector runs whenever it likes, so nothing equivalent can exist.class Connection {
let name: String
init(name: String) {
self.name = name
print("open \(name)")
}
deinit { print("close \(name)") }
}
do {
let connection = Connection(name: "db")
print("work with \(connection.name)")
}
print("after the scope")class Connection implements AutoCloseable {
private final String name;
Connection(String name) { this.name = name; System.out.println("open " + name); }
String name() { return name; }
public void close() { System.out.println("close " + name); }
}
class Main {
public static void main(String[] args) {
try (Connection connection = new Connection("db")) {
System.out.println("work with " + connection.name());
}
System.out.println("after the scope");
}
}Java has
finalize, which is deprecated for removal, and Cleaner, which is for safety nets rather than for cleanup you depend on — neither is guaranteed to run at all. So every resource is released explicitly: AutoCloseable plus try-with-resources is the pattern, and it is enforced by convention and by static analysis rather than by the language. The rule for someone arriving from Swift: if it has a close(), it belongs in the parentheses of a try.No weak, no unowned, no retain cycles
A tracing collector finds unreachable objects by walking from the roots, so a cycle between two dead objects is simply unreachable and is collected. Reference counting cannot do that, which is why Swift has
weak and unowned.class Node {
let name: String
var child: Node?
weak var parent: Node? // weak, or these two leak
init(name: String) { self.name = name }
}
let parent = Node(name: "parent")
let child = Node(name: "child")
parent.child = child
child.parent = parent
print(child.parent?.name ?? "none")class Node {
final String name;
Node child;
Node parent; // an ordinary reference; the cycle is fine
Node(String name) { this.name = name; }
}
class Main {
public static void main(String[] args) {
Node parent = new Node("parent");
Node child = new Node("child");
parent.child = child;
child.parent = parent;
System.out.println(child.parent.name);
}
}This is the one place where the JVM's memory model is straightforwardly easier: parent pointers, delegates and closures capturing
self need no annotation and leak nothing. WeakReference exists and is for caches and listener registries — cases where you want the collector to be able to reclaim something — not for breaking cycles. What replaces retain cycles as the leak to watch for is the long-lived collection: a static map or a listener list that nobody removes from keeps its contents alive for the life of the process.No copy-on-write, so copies are real
Swift's collections give you value semantics without paying for them until someone writes. Java has neither the semantics nor the optimisation.
var first = [1, 2, 3]
var second = first // no copy yet — copy-on-write
second.append(4) // NOW it copies
print(first.count, second.count)import java.util.ArrayList;
import java.util.List;
class Main {
public static void main(String[] args) {
List<Integer> first = new ArrayList<>(List.of(1, 2, 3));
List<Integer> second = new ArrayList<>(first); // copies immediately
second.add(4);
System.out.println(first.size() + " " + second.size());
}
}A defensive copy in Java is an eager allocation and an element-by-element copy, every time — so the advice to copy on the way in and on the way out has a cost that Swift's does not, and it is one reason immutable collections (
List.of, List.copyOf, and Guava's immutable family) are preferred: they can be shared freely because nobody can change them. List.copyOf is also smart enough to return the argument unchanged when it is already immutable.Concurrency
async/await against a Future
Java has no
async and no await. The closest thing is CompletableFuture, which is a promise: you chain callbacks onto it, or block on it.func fetch(_ id: Int) async -> String {
"record \(id)"
}
let value = await fetch(1)
print(value)import java.util.concurrent.CompletableFuture;
class Main {
static CompletableFuture<String> fetch(int id) {
return CompletableFuture.supplyAsync(() -> "record " + id);
}
public static void main(String[] args) throws Exception {
System.out.println(fetch(1).get());
}
}So asynchronous code is written in the callback style Swift left behind —
thenApply, thenCompose (which is flatMap), thenCombine, exceptionally — and a chain of five reads much worse than five awaits. get() blocks the calling thread, which is exactly what you must not do on a UI thread and is fine in a main like this one. Java's actual answer to callback fatigue is not syntax but virtual threads: with a thread that is cheap to block, ordinary sequential code becomes the concurrent style, which the next rows cover.A task group against a list of futures
Running several things and waiting for all of them is a task group in Swift and a list of futures in Java — with one guarantee missing on the Java side.
func fetch(_ id: Int) async -> Int { id * 10 }
let results = await withTaskGroup(of: Int.self) { group in
for id in 1...3 { group.addTask { await fetch(id) } }
var collected: [Int] = []
for await value in group { collected.append(value) }
return collected.sorted()
}
print(results)import java.util.List;
import java.util.concurrent.CompletableFuture;
class Main {
static CompletableFuture<Integer> fetch(int id) {
return CompletableFuture.supplyAsync(() -> id * 10);
}
public static void main(String[] args) {
List<CompletableFuture<Integer>> futures =
List.of(fetch(1), fetch(2), fetch(3));
List<Integer> results = futures.stream().map(CompletableFuture::join).sorted().toList();
System.out.println(results);
}
}A Swift task group is structured: the group cannot outlive its scope, a failure cancels the siblings, and cancellation propagates down. A list of
CompletableFutures has none of that — cancelling one does not touch the others, and a future you forget about keeps running. CompletableFuture.allOf(...) waits for a whole array, and JEP 505's StructuredTaskScope brings genuine structured concurrency to Java, still in preview at JDK 25. This row does run in the browser, incidentally: three supplyAsync tasks share the common pool's single worker thread, so they complete one after another rather than at once.An actor becomes a lock
An actor guarantees that its mutable state is touched by one task at a time, and the compiler enforces it. Java's
synchronized makes the same guarantee at run time, for the methods you remember to mark.actor Counter {
private var total = 0
func increment() { total += 1 }
func value() -> Int { total }
}
let counter = Counter()
await counter.increment()
await counter.increment()
print(await counter.value())class Counter {
private int total = 0;
synchronized void increment() { total += 1; }
synchronized int value() { return total; }
}
class Main {
public static void main(String[] args) {
Counter counter = new Counter();
counter.increment();
counter.increment();
System.out.println(counter.value());
}
}The gap is enforcement, and it is the whole of Swift 6's strict concurrency: nothing in Java stops you reading
total from another thread without the lock, and the compiler will not tell you that a field is shared. There is no Sendable, no data-race safety checking, and no compile error for capturing mutable state in a task. What Java offers instead is a well-stocked toolbox — synchronized, ReentrantLock, AtomicInteger, ConcurrentHashMap, the java.util.concurrent collections — and the discipline to use it.A data race the compiler will not catch
This is the row that states the cost of leaving Swift 6. The same mistake is a compile error there and a silently wrong number here.
// Swift 6 rejects this at COMPILE time: a mutable global
// captured by concurrent tasks is a data race.
// var total = 0
// await withTaskGroup(of: Void.self) { group in
// for _ in 0..<2 { group.addTask { total += 1 } } // error
// }
// The safe version is an actor, as in the previous row.
print("Swift 6 makes this a compile error, not a bug report")class Main {
static int total = 0;
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> { for (int i = 0; i < 100_000; i++) total += 1; };
Thread first = new Thread(work);
Thread second = new Thread(work);
first.start(); second.start();
first.join(); second.join();
System.out.println(total <= 200_000 ? "at most 200000, usually less" : "impossible");
}
}total += 1 is a read, an add and a write, so two threads interleave and updates are lost — the printed total is usually well under 200,000 and varies between runs. (Shown rather than run: the servers that run the Java here allow only one thread beyond main, and losing an update takes two.) Nothing in Java warns you: not the compiler, not the type system, not a runtime check. The fixes are synchronized, an AtomicInteger, or not sharing the variable.Virtual threads: blocking becomes cheap again
Virtual threads (JDK 21) are Java's answer to the same problem
async/await solves: a thread that is cheap enough to create a million of, and cheap enough to block.// Swift's model: many tasks share a small pool of OS threads, and
// suspending at an await never blocks one of them.
func fetch(_ id: Int) async -> Int { id }
let total = await withTaskGroup(of: Int.self) { group in
for id in 1...1000 { group.addTask { await fetch(id) } }
var sum = 0
for await value in group { sum += value }
return sum
}
print(total)import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
class Main {
public static void main(String[] args) throws Exception {
AtomicInteger total = new AtomicInteger();
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int id = 1; id <= 1000; id++) {
int value = id;
pool.submit(() -> total.addAndGet(value));
}
}
System.out.println(total.get());
}
}The consequence is the opposite of Swift's: rather than marking functions
async and awaiting, you write ordinary blocking code and let the runtime unmount the virtual thread while it waits. No colouring, no await, no separate ecosystem of async libraries — the existing blocking ones become efficient. The catch is that blocking inside a synchronized block pins the carrier thread, which JDK 24 largely fixed. Underneath, a virtual thread costs two real threads: a carrier to run it and an unblocker to wake it — which is one more than the servers that run the Java here allow, so this example is shown rather than run.Packaging & Tooling
SwiftPM against Maven and Gradle
The build story is the least similar part of the move, and the main thing to absorb is that there are two ecosystems rather than one.
// Package.swift declares products, targets and dependencies in Swift.
// swift build / swift test / swift run
// One tool, one manifest, one lockfile (Package.resolved).
print("swift build")// pom.xml (Maven, XML) or build.gradle.kts (Gradle, Kotlin DSL)
// mvn package / mvn test — ./gradlew build / ./gradlew test
// Two dominant tools, and the choice is usually made for you.
System.out.println("mvn package");Maven is declarative XML with a fixed lifecycle and is common on the backend; Gradle is a programmable DSL and is what Android uses, so an iOS developer sent to Android will meet Gradle first. Both resolve from Maven Central, both cache in
~/.m2 or ~/.gradle, and neither has a lockfile by default — versions are pinned in the build file, and transitive resolution picks the nearest version, which is the source of most "it works on my machine" on the JVM. There is no equivalent of Xcode as the single blessed editor: IntelliJ, Eclipse and VS Code all work.Tests, annotations and reflection
Tests look similar — an annotation on a method, a runner that finds it — but the mechanism underneath is one of the biggest cultural differences on the JVM.
// Swift Testing (or XCTest):
// import Testing
// @Test func doubling() { #expect(double(21) == 42) }
func double(_ value: Int) -> Int { value * 2 }
print(double(21) == 42)// JUnit 5:
// @Test void doubling() { assertEquals(42, double(21)); }
// The annotation is READ AT RUN TIME by reflection — that is how the
// runner finds the method, and how Spring, Jackson and JPA work too.
class Main {
static int doubleValue(int value) { return value * 2; }
public static void main(String[] args) {
System.out.println(doubleValue(21) == 42);
}
}A Java annotation is metadata that survives into the class file, and reflection lets code inspect classes, read those annotations and call methods by name at run time. That is how JUnit discovers tests, how Spring wires dependencies, how Jackson maps JSON to fields, and how mocking libraries work — a whole style of framework that Swift's macros and property wrappers approach from the other direction. It also means these frameworks fail at run time rather than at compile time, and that erased generics and reflection together explain most of the JVM's configuration culture.