Types, Generics & Nullability
Post 1 changed how you think about Java values: everything is passed by value, == asks "same object?", .equals() asks "same value?", types are checked at compile time, and String is immutable. This post is about the type system itself — the machinery behind those rules. For a Python developer, Java's type system is the biggest daily difference you'll feel, and it will either read as bureaucracy or as a second pair of eyes. Which one depends on understanding what it's actually doing.
The honest frame for this whole post: every code block below ran on JDK 21 on my machine, and every output block is the real output, pasted verbatim. Python comparisons ran on Python 3.12. Where I show a compile error, I show the real javac message. Nothing here is "trust me."
1. Two worlds: primitives vs wrapper classes
In Python, every value is an object — 42 is a full int object, and Python integers have arbitrary precision. Java splits its world in two. Primitives are raw values with fixed sizes sitting directly in variables — no object, no methods, no null. Wrapper classes are real objects that wrap one primitive, and they live in java.lang. Here's the full map:
| Python type | Java primitive | Size | Wrapper class |
|---|---|---|---|
bool | boolean | 1 bit (JVM-dependent) | Boolean |
int (arbitrary precision) | byte / short / int / long | 1 / 2 / 4 / 8 bytes | Byte / Short / Integer / Long |
float | float / double | 4 / 8 bytes | Float / Double |
str (single char is still a str) | char | 2 bytes (UTF-16) | Character |
int is 32 bits and overflows silently — Integer.MAX_VALUE + 1 wraps to a negative number, no error. (You'll see this exact trap bite in the performance section below.) Second: a char is not a one-character string — it's a 16-bit number. 'A' + 1 is the integer 66, and casting back gives 'B'. Strings are String, characters are char, and never the twain shall meet without an explicit conversion.Java converts between the two worlds automatically — autoboxing (primitive → wrapper) and unboxing (wrapper → primitive). It looks free. It isn't. But first, watch it work:
import java.util.ArrayList;
import java.util.List;
public class BoxingDemo {
public static void main(String[] args) {
// Autoboxing: int -> Integer (assignment does Integer.valueOf)
Integer boxed = 42;
// Unboxing: Integer -> int
int primitive = boxed;
System.out.println("boxed = " + boxed + " (" + boxed.getClass().getSimpleName() + ")");
System.out.println("primitive = " + primitive);
// Unboxing in arithmetic
Integer a = 7, b = 6;
int sum = a + b; // both unboxed, then added
System.out.println("a + b = " + sum);
// Collections of primitives are really collections of wrappers
List<Integer> ints = new ArrayList<>();
ints.add(1); ints.add(2); ints.add(3); // int -> Integer on the way in
int first = ints.get(0); // Integer -> int on the way out
System.out.println("list.get(0) = " + first);
}
}
Real output:
boxed = 42 (Integer)
primitive = 42
a + b = 13
list.get(0) = 1
List<int> doesn't exist — you write List<Integer> and autoboxing silently bridges the gap. This is why Java performance discussions always come back to boxing: your "list of ints" is secretly a list of objects.The Integer-cache trap (post 1 comes back to haunt you)
Post 1 taught you: == compares object identity, .equals() compares values. Now watch that rule collide with autoboxing. The JVM caches Integer objects for values -128 to 127 (the language spec guarantees it). Inside that range, two boxes of the same value are the same object; outside it, they aren't:
public class IntegerCacheTrap {
public static void main(String[] args) {
Integer a127 = Integer.valueOf(127);
Integer b127 = Integer.valueOf(127);
System.out.println("127: a == b -> " + (a127 == b127)); // cached: same object
Integer a128 = Integer.valueOf(128);
Integer b128 = Integer.valueOf(128);
System.out.println("128: a == b -> " + (a128 == b128)); // NOT cached: different objects
System.out.println("128: a.equals(b) -> " + a128.equals(b128)); // value comparison: true
System.out.println("identityHashCode 127: " + System.identityHashCode(a127)
+ " vs " + System.identityHashCode(b127));
System.out.println("identityHashCode 128: " + System.identityHashCode(a128)
+ " vs " + System.identityHashCode(b128));
}
}
Real output:
127: a == b -> true
128: a == b -> false
128: a.equals(b) -> true
identityHashCode 127: 2125039532 vs 2125039532
identityHashCode 128: 1581781576 vs 1725154839
Read that again: 127 == 127 is true and 128 == 128 is false — on boxed integers. The identityHashCode lines prove it: identical hashes at 127 (same object), different hashes at 128 (two distinct objects with equal values). In Python, is on small ints has a similar cache, but Python style is to use == for values anyway — which is exactly the Java lesson too. On wrapper objects, == is a lie detector you didn't ask for. Use .equals(). This bug passes every test with small numbers and breaks in production with large ones, because the cache boundary (-128..127) sits right in the middle of typical test data.
2. Autoboxing has a price — measured, not theorized
"Autoboxing is convenient" is theory. Here's one measured run on my machine — summing 0 to 10,000,000 as a primitive long versus a boxed Long. (Why long? The true sum is 49,999,995,000,000 — far past int's 2.1-billion ceiling. My first draft of this benchmark used Integer and the boxed sum silently overflowed to -2014260032 while the primitive long sum was correct — which is itself a lesson: overflow is silent, and boxing doesn't change it. I switched both sides to long/Long so the comparison is fair.)
public class BoxingPerf {
static final int N = 10_000_000; // sum exceeds int range, so we compare long vs Long
public static void main(String[] args) {
long t0 = System.nanoTime();
long sumPrimitive = 0; // all primitive long
for (int i = 0; i < N; i++) {
sumPrimitive += i;
}
long primitiveMs = (System.nanoTime() - t0) / 1_000_000;
t0 = System.nanoTime();
Long sumBoxed = 0L; // Long: allocate + unbox on every iteration
for (int i = 0; i < N; i++) {
sumBoxed += i;
}
long boxedMs = (System.nanoTime() - t0) / 1_000_000;
System.out.println("N = " + N);
System.out.println("long sum = " + sumPrimitive + ", took " + primitiveMs + " ms");
System.out.println("Long sum = " + sumBoxed + ", took " + boxedMs + " ms");
}
}
Real output, one run:
N = 10000000
long sum = 49999995000000, took 34 ms
Long sum = 49999995000000, took 355 ms
Long loop allocates ten million Long objects and unboxes every one of them. The primitive loop allocates nothing. For a hot loop, that 10x is real money; for code that runs once per request, it's noise. The rule isn't "never box" — it's "don't box in the hot path," and now you know what boxing actually costs: an allocation per value, every iteration.3. Generics: the compiler as a bouncer
In Python, a list holds anything and you find out at runtime:
# Python: no declared element type — the bug arrives at runtime
mixed = [1, "two", 3.0, None]
print([type(v).__name__ for v in mixed]) # ['int', 'str', 'float', 'NoneType']
Pre-generics Java had the same shape — a raw List holding anything, with a ClassCastException waiting at runtime. Generics moved that failure to compile time, which is the whole point. Watch both, side by side:
import java.util.ArrayList;
import java.util.List;
public class GenericsDemo {
public static void main(String[] args) {
// Raw list (pre-generics style): compiles, fails at runtime
List raw = new ArrayList();
raw.add("hello");
raw.add(42);
System.out.println("raw list contents: " + raw);
try {
String s = (String) raw.get(1); // ClassCastException at runtime
System.out.println(s);
} catch (ClassCastException e) {
System.out.println("RAW: caught ClassCastException: " + e.getMessage());
}
// Generic list: the WRONG ADD refuses to compile (shown commented out)
List<String> safe = new ArrayList<>();
safe.add("hello");
// safe.add(42); // <-- compile error: incompatible types
String s = safe.get(0); // no cast needed
System.out.println("generic list: " + safe + ", get(0) = " + s);
// Generic class + generic method
Box<String> nameBox = new Box<>("Grace");
System.out.println("Box holds: " + nameBox.get()
+ " (" + nameBox.get().getClass().getSimpleName() + ")");
Box<Integer> countBox = new Box<>(42);
System.out.println("Box holds: " + countBox.get()
+ " (" + countBox.get().getClass().getSimpleName() + ")");
System.out.println("sum of ints: " + total(List.of(1, 2, 3)));
System.out.println("sum of doubles: " + total(List.of(1.5, 2.5)));
}
/** One generic class instead of a Box class per type. */
static class Box<T> {
private final T value;
Box(T value) { this.value = value; }
T get() { return value; }
}
/** One generic method that sums any list of numbers. */
static <T extends Number> double total(List<T> nums) {
double total = 0;
for (T n : nums) total += n.doubleValue();
return total;
}
}
Real output (including the compiler's own honesty note about the raw list — those two Note: lines are real, and they're the compiler telling you raw types are unsafe):
Note: GenericsDemo.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
raw list contents: [hello, 42]
RAW: caught ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
generic list: [hello], get(0) = hello
Box holds: Grace (String)
Box holds: 42 (Integer)
sum of ints: 6.0
sum of doubles: 4.0
Two things to notice. First, the Python parallel: this is what list[int] type hints do in a mypy-checked codebase — declare the element type, catch the wrong add before it ships. The difference is that in Java the compiler always checks; there's no "run mypy if you remember" step. Second, the bouncer works both ways: uncommenting safe.add(42) produces this real error, and the program never runs:
GAdd.java:2: error: incompatible types: int cannot be converted to String
public class GAdd { public static void main(String[] a){ List<String> s = new ArrayList<>(); s.add(42); } }
^
1 error
ClassCastException at 2 AM instead of at compile time.Wildcards: <? extends T> vs <? super T> — PECS
The one generics question that haunts everyone: when do you write extends and when super? The rule is PECS: Producer Extends, Consumer Super. If a list produces values for you to read, bound it with ? extends T. If it consumes values you write into it, bound it with ? super T. Here's the canonical worked example — copying apples and oranges into a fruit bowl:
import java.util.ArrayList;
import java.util.List;
public class PecsDemo {
static class Fruit { public String toString() { return "fruit"; } }
static class Apple extends Fruit { public String toString() { return "apple"; } }
static class Orange extends Fruit { public String toString() { return "orange"; } }
/** Copy everything from src into dst. */
static <T> void copyAll(List<? extends T> src, List<? super T> dst) {
for (T item : src) { // src is a producer: we READ T out of it
dst.add(item); // dst is a consumer: we WRITE T into it
}
}
public static void main(String[] args) {
List<Apple> apples = new ArrayList<>(List.of(new Apple(), new Apple()));
List<Orange> oranges = new ArrayList<>(List.of(new Orange()));
List<Fruit> fruitBowl = new ArrayList<>();
copyAll(apples, fruitBowl); // List<Apple> is a producer of Fruit
copyAll(oranges, fruitBowl); // List<Orange> is a producer of Fruit
System.out.println("fruitBowl = " + fruitBowl);
// And it still works for the simple same-type case:
List<String> names = new ArrayList<>(List.of("a", "b"));
List<Object> objects = new ArrayList<>();
copyAll(names, objects); // List<String> -> List<Object>: ? super Object accepts String
System.out.println("objects = " + objects);
}
}
Real output:
fruitBowl = [apple, apple, orange]
objects = [a, b]
Why this signature and not copyAll(List<T> src, List<T> dst)? Because List<Apple> is not a List<Fruit> — generics are invariant. If they weren't, you could add an Orange into what someone else believes is a list of Apple. The wildcard says exactly what you mean: "I read Ts from here" and "I write Ts into here."
And here's what happens when you get the bound wrong — you declare the list a producer (? extends) and then try to write through it. The compiler's error is real and worth reading slowly:
import java.util.ArrayList;
import java.util.List;
public class WildcardErrorDemo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("grace");
// We need to ADD Strings into this list, so it must be a CONSUMER (? super).
// Declaring it as a producer (? extends) makes add() illegal:
List<? extends Object> producer = names;
producer.add("hopper"); // ERROR: nothing can be added through a ? extends reference
}
}
WildcardErrorDemo.java:11: error: incompatible types: String cannot be converted to CAP#1
producer.add("hopper"); // ERROR: nothing can be added through a ? extends reference
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ? extends Object
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
CAP#1 is the compiler's name for "some specific but unknown subtype of Object." It can't let you add a String because the list might really be a List<SomeOtherSubtype>. That's not pedantry — it's the bouncer protecting the invariant. The mnemonic again: reading → extends, writing → super. If you take one trick from this post, take this one.
4. Type erasure: the generics are a compile-time fiction
Here's the sentence that reframes everything you just learned: at runtime, List<String> and List<Integer> are the same class. The type arguments exist only in the compiler's head. The compiler checks every add and get, then erases the parameters and emits plain List bytecode. Run it and see:
import java.util.ArrayList;
import java.util.List;
public class ErasureDemo {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
System.out.println("strings.getClass() = " + strings.getClass());
System.out.println("ints.getClass() = " + ints.getClass());
System.out.println("same class? " + (strings.getClass() == ints.getClass()));
// And the raw type check agrees:
System.out.println("strings instanceof List -> " + (strings instanceof List));
}
}
Real output:
strings.getClass() = class java.util.ArrayList
ints.getClass() = class java.util.ArrayList
same class? true
strings instanceof List -> true
Here's the diagram to keep in your head — compile time on the left, runtime on the right:
Erasure is not trivia — it dictates what Java can't do, and the errors prove it. Three hard limits, all real compiler output:
You can't instantiate a type parameter — at runtime T is Object; the compiler has no class to call new on:
ErasureReal.java:4: error: unexpected type
return new T();
^
required: class
found: type parameter T
where T is a type-variable:
T extends Object declared in method <T>make()
You can't instanceof a parameterized type — there'd be nothing to check, since the parameter is gone:
ErasureReal.java:7: error: Object cannot be safely cast to List<String>
if (o instanceof List<String>) {
^
2 errors
You can't overload on the type argument — after erasure both signatures are f(List):
Ovl.java:2: error: name clash: f(List<Integer>) and f(List<String>) have the same erasure
public class Ovl { void f(List<String> a){} void f(List<Integer> a){} }
^
1 error
StringBox extends Box<String> overriding T get() with String get() — the compiler quietly generates a second, synthetic Object get() that delegates to the String one, so erased callers still link. javap shows the ghost (real output):class StringBox extends Box<java.lang.String> {
StringBox(java.lang.String);
java.lang.String get();
java.lang.Object get(); // <-- the synthetic bridge method
}
You will almost never write a bridge method yourself. But when a stack trace or a reflection dump shows a method you never wrote, now you know where it came from: erasure, patching the hole it left.list[int] is erased at runtime too — type([]) tells you nothing about the element type either. The difference is only that Java's compiler enforces the annotation before erasing it. When you wonder "why can't I do X with generics?" the answer is nearly always one word: erasure.5. var: type inference, not dynamic typing
Java 10 added var, and every Python developer reads it as "finally, dynamic typing." It is the opposite: var is the compiler inferring a fixed, static type from the initializer. The type is locked at declaration and never changes:
import java.util.List;
public class VarDemo {
public static void main(String[] args) {
var count = 42; // inferred as int, locked in forever
System.out.println("count = " + count);
var name = "grace"; // inferred as String
System.out.println("name = " + name.toUpperCase());
var names = List.of("a", "b"); // inferred as List<String>
System.out.println("names.size() = " + names.size());
for (var n : names) { // n is String — no cast needed
System.out.println("n.length() = " + n.length());
}
}
}
Real output:
count = 42
name = GRACE
names.size() = 2
n.length() = 1
n.length() = 1
And the proof that it's not dynamic typing — assigning a String to the int-inferred count is a compile error, same as if you'd written int:
VarErrorDemo.java:5: error: incompatible types: String cannot be converted to int
count = "hello"; // ERROR: a String can never be an int, var or not
^
1 error
var goes and where it doesn't. var is for local variables (and for-loop variables) only — not fields, not parameters, not return types. Your class's public surface still declares every type explicitly; var just spares you from writing HashMap<String, List<Integer>> twice inside a method body. Compare with Python, where the dynamic-typing demo below is perfectly legal:x = 42; print(type(x).__name__, "=", x) # int = 42
x = "hello"; print(type(x).__name__, "=", x) # str = hello
x = None; print(type(x).__name__, "=", x) # NoneType = None
Real Python output: int = 42, str = hello, NoneType = None. In Java, var would freeze x as int on the first line and reject the rest. Same keyword shape, opposite semantics.6. Nullability: null is not None
Python's None is a well-behaved value: you can pass it around, store it, compare it, and phonebook.get("hopper") returning None is just a fact you branch on. Java's null is the absence of a reference — the moment you touch it (call a method, read a field), the JVM throws NullPointerException. Same "missing value" idea, opposite temperament: None waits politely, null explodes on contact.
The good news first: since JDK 14, NPEs come with helpful null messages that name the exact dereference that was null. This ran on JDK 21:
import java.util.HashMap;
import java.util.Map;
public class NpeDemo {
record Team(String name, Map<String, String> leadBy) {}
public static void main(String[] args) {
Map<String, String> leads = new HashMap<>();
leads.put("backend", "grace");
// note: no "frontend" key — map.get returns null
Team team = new Team("cityops", leads);
try {
int len = team.leadBy().get("frontend").length(); // NPE here
System.out.println(len);
} catch (NullPointerException e) {
System.out.println("Caught NullPointerException. Message:");
System.out.println(e.getMessage());
}
}
}
Real output:
Caught NullPointerException. Message:
Cannot invoke "String.length()" because the return value of "java.util.Map.get(Object)" is null
That's genuinely useful — the old message was just null and a stack trace. Now the JVM tells you which link in the chain was null. But diagnosis isn't prevention. The prevention toolkit, in order of preference:
1. Objects.requireNonNull — fail fast, with a name. Validate at the boundary so a null blows up at the parameter that was null, with the parameter's name, instead of three methods later:
Objects.requireNonNull(name, "name must not be null");
// real output when violated: java.lang.NullPointerException: name must not be null
2. Optional — explicit absence, done right. Optional<T> is a container that holds zero or one value. Used correctly, it replaces "check for null, branch, hope" with a small vocabulary: map, flatMap, orElse, ifPresentOrElse. The idiom: return Optional from lookup-style methods, and decide what absence means at the boundary:
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public class OptionalDemo {
static Map<String, String> phonebook = new HashMap<>(Map.of("grace", "555-0100"));
/** RIGHT: Optional for a value that may be absent. */
static Optional<String> lookup(String name) {
return Optional.ofNullable(phonebook.get(name));
}
/** RIGHT: decide at the boundary, with map/orElse. No get() in sight. */
static String greeting(String name) {
return lookup(name)
.map(num -> "Calling " + name + " at " + num + " ...")
.orElse("No number for " + name + " - using the front desk.");
}
public static void main(String[] args) {
// requireNonNull: fail fast with a named parameter, not a mystery NPE later
try {
Objects.requireNonNull(null, "name must not be null");
} catch (NullPointerException e) {
System.out.println("requireNonNull -> " + e.getMessage());
}
// The right way
System.out.println(greeting("grace"));
System.out.println(greeting("hopper"));
// The WRONG way: Optional.get() without a check is just NPE with extra steps
try {
String num = lookup("hopper").get(); // NoSuchElementException
System.out.println(num);
} catch (java.util.NoSuchElementException e) {
System.out.println("WRONG way -> NoSuchElementException: " + e.getMessage());
}
// Optional is not Java's None: orElse / orElseGet / ifPresentOrElse are the vocabulary
lookup("ada").ifPresentOrElse(
num -> System.out.println("found: " + num),
() -> System.out.println("not found, no exception, no null - just a branch"));
}
}
Real output:
requireNonNull -> name must not be null
Calling grace at 555-0100 ...
No number for hopper - using the front desk.
WRONG way -> NoSuchElementException: No value present
not found, no exception, no null - just a branch
Compare with the Python version, where None is just returned and branched on — no container needed:
def lookup(phonebook, name):
return phonebook.get(name) # None when absent, like Optional.empty()
print(lookup(phonebook, "grace")) # 555-0100
print(lookup(phonebook, "hopper")) # None — but not an exception
Optional is not "Java's None." None is a value you can store, pass, and return freely; Optional is a container with a contract. The idiom: use it as a return type for lookups, never as a field (a field should be null-checked or non-null by construction), never for collections (return an empty list, not Optional<List>), and never call .get() without a check — that's just a NoSuchElementException wearing a trench coat. And note the last trap the demo implies: Optional.of(null) itself throws NullPointerException (real output: java.lang.NullPointerException — note the empty message). The safe constructor for "maybe null" is ofNullable.7. Records: Java's answer to dataclasses
You know Python's @dataclass — a compact way to declare "this class is just data":
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p1 = Point(3, 4); p2 = Point(3, 4)
print("repr:", p1) # repr: Point(x=3, y=4)
print("equals:", p1 == p2) # equals: True
Java's record (stable since 16) is the same idea, enforced by the language. One line declares the components; the compiler generates the accessor methods, equals, hashCode, and toString. To feel how much it saves, here's the record next to the equivalent hand-written class:
import java.util.Objects;
public class RecordDemo {
/** One line: accessor, equals, hashCode, toString — all generated. */
record Point(int x, int y) {}
/** The equivalent class, written out by hand. */
static final class PointClass {
private final int x;
private final int y;
PointClass(int x, int y) { this.x = x; this.y = y; }
int x() { return x; }
int y() { return y; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PointClass p)) return false;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "PointClass[x=" + x + ", y=" + y + "]"; }
}
public static void main(String[] args) {
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
System.out.println("record toString: " + p1);
System.out.println("record equals: " + p1.equals(p2));
System.out.println("record hashCode: " + p1.hashCode() + " == " + p2.hashCode()
+ " -> " + (p1.hashCode() == p2.hashCode()));
PointClass c1 = new PointClass(3, 4);
PointClass c2 = new PointClass(3, 4);
System.out.println("class toString: " + c1);
System.out.println("class equals: " + c1.equals(c2));
// Accessors: p.x() — no getX()
System.out.println("record accessor: p1.x() = " + p1.x());
}
}
Real output:
record toString: Point[x=3, y=4]
record equals: true
record hashCode: 97 == 97 -> true
class toString: PointClass[x=3, y=4]
class equals: true
record accessor: p1.x() = 3
One line versus thirty, identical behavior — including hashCode consistency (equal objects, equal hashes: 97 == 97, so records are safe as map keys out of the box). Three differences from dataclasses worth internalizing:
- Records are implicitly final and shallowly immutable. All fields are
final; a record can't extend another class (it implicitly extendsjava.lang.Record). A dataclass is mutable by default unless you passfrozen=True— the record starts from the stricter end, which matches post 1's immutability theme. - Accessors are named after the component —
p.x(), notp.getX(). This trips up every Java veteran and Python convert alike; your IDE will autocompletegetX()and it won't exist. - Records can still have behavior. You can add methods and even validate in a compact constructor (
Point { if (x < 0) throw ...; }). They're "data-first," not "data-only."
record when the thing is its data: DTOs, API request/response shapes, config snapshots, map keys, tuple-like return values. Reach for a class when it needs mutable state, inheritance, or behavior that owns the data. If you find yourself writing a class whose fields are all final and whose methods are getters plus equals/hashCode/toString — that's a record wearing a disguise.8. Pattern matching instanceof — and a sealed-class teaser
The pre-generics ClassCastException in section 3 needed a cast after the instanceof check. Pattern matching merges the two: check the type and bind a variable of that type in one step, no cast:
public class PatternMatchDemo {
public static void main(String[] args) {
Object[] values = {42, "hello", 3.14, true, null};
for (Object v : values) {
// The classic way (still valid): cast after instanceof
if (v instanceof Integer) {
Integer i = (Integer) v;
System.out.println("classic: Integer " + i + ", squared = " + (i * i));
}
// Pattern matching: no cast, the binding flows into the if body
if (v instanceof Integer i) {
System.out.println("pattern: Integer " + i + ", squared = " + (i * i));
} else if (v instanceof String s && !s.isEmpty()) {
System.out.println("pattern: String of length " + s.length());
} else if (v == null) {
System.out.println("pattern: null is not an instance of anything");
} else {
System.out.println("pattern: something else (" + v.getClass().getSimpleName() + ")");
}
}
// instanceof is also false for null — the binding just never happens
Object nothing = null;
System.out.println("null instanceof String -> " + (nothing instanceof String));
}
}
Real output:
classic: Integer 42, squared = 1764
pattern: Integer 42, squared = 1764
pattern: String of length 5
pattern: something else (Double)
pattern: something else (Boolean)
pattern: null is not an instance of anything
null instanceof String -> false
(Yes, both branches fire for 42 — the demo runs the classic and the pattern version on purpose, so you can compare. The pattern version is strictly less code with no cast to get wrong.)
sealed interface Shape permits Circle, Square { }. A sealed hierarchy is closed — no surprise subclasses — which means a switch with patterns over it can be checked for exhaustiveness at compile time: handle every permitted type and the compiler proves you didn't miss one. That's the direction modern Java is walking — closed types + pattern matching — and post 4's ecosystem tour will show you where Spring and the standard library already lean on it.9. The whole map: Python typing → Java
One table to bookmark. The left column is the Python concept you already know; the right is the Java equivalent you now understand, with the section to revisit:
| Python | Java | The difference that matters |
|---|---|---|
| Dynamic typing — a name holds anything | Static typing — every variable, parameter, field declares its type | §1–5: the type is fixed at declaration, checked by the compiler, enforced always |
int, float, bool (objects, arbitrary precision) | Primitives (int, long, double, boolean…) + wrapper classes (Integer, Long…) | §1: fixed sizes, silent overflow, and autoboxing bridging the two worlds |
Type hints: list[int], dict[str, int] | Generics: List<Integer>, Map<String, Integer> | §3: Java's are enforced by the compiler, never optional; then erased at runtime (§4) |
typing.TypeVar("T") | Type parameter <T> on classes and methods | §3: same "write once, work for many types" idea, with bounds (<T extends Number>) |
list[int] | list[str] (unions) | Wildcards: List<? extends Fruit>, List<? super T> | §3: PECS — reading → extends, writing → super |
Optional[X] / X | None | Optional<T> | §6: a container, not a value — return it from lookups, map/orElse at the boundary, never .get() unchecked |
None | null | §6: None is a value you branch on; null throws NPE on contact — with helpful messages since JDK 14 |
@dataclass / NamedTuple | record | §7: one line, equals/hashCode/toString generated, implicitly final; accessors are x() not getX() |
Protocol (structural / duck typing) | interface (nominal typing) | A Java class must declare implements — matching method shapes isn't enough. (Post 4 touches the ecosystem side of this.) |
@overload (type-checker fiction) | Method overloading (real dispatch) | Java picks the overload at compile time by argument types — but not by generic type argument (§4: same erasure!) |
isinstance(x, int) + cast-by-convention | instanceof with pattern binding | §8: if (v instanceof Integer i) — no cast, and null is never an instance of anything |
var infers a fixed type, it doesn't loosen one; null explodes where None waits, so Optional carries absence explicitly; records are dataclasses with the compiler doing the boilerplate. Every one of these exists to move a class of bug from 2 AM to compile time. Let the compiler be the bouncer — your job is to write the guest list.Field check: earn the type system yourself
Reading this post rents you the ideas; running these buys them. Do all four on your own machine (JDK 21, plain javac/java, no dependencies):
- Extend the cache trap. Write a program that compares
Integer.valueOf(n) == Integer.valueOf(n)for n from 120 to 135 and prints the results. Mark the exact boundary wheretrueflips tofalse, then predict — before running — whatLong.valueOf(127) == Long.valueOf(127)gives. If you were wrong, find the cache rule forLongand explain it. - Break the bound on purpose. Take the PECS
copyAllmethod and deliberately flip the wildcards (? super Ton the source,? extends Ton the destination). Read the realjavacerrors, then explain in one sentence each why the compiler rejects thesrcread and thedstwrite. - Benchmark your own machine. Run the
BoxingPerfprogram from section 2 three times and record thelongvsLongtimings. Then change the loop to accumulate into alongwhile reading from aList<Long>— how much of the cost is the unboxing on read versus the allocation on write? Write down your hypothesis before you run. - Record your dataclass. Pick the most dataclass-like class in your Python codebase (or write a small
Order/Customerpair), port it to a Javarecordwith a compact-constructor validation (reject a blank name or negative price), and printtoString,equals, andhashCodefor two equal instances. Then deliberately try to make the record extend a class, and read the compiler's refusal.
What's next
You now think in Java's type system: two worlds of values, generics as an enforced contract, erasure as the reason behind the limits, absence handled explicitly, and records for data. The last bridge post zooms out from the language to the world around it — the ecosystem you'll actually work in:
Next: The JVM Ecosystem: Maven Central, Spring, and What's Standard
And the full map of everything published so far — every post, every track, in order:
Continue: Java Learning Roadmap 2026
Comments
Post a Comment