Side by side
Python ↔ Java
Curated pairs where the two languages solve the same underlying problem in genuinely comparable ways — not a forced mapping between unrelated features. More pairs get added as new topics land.
Identity vs. equality, and reference caching
`is` vs `==`, the small-int cache, and string interning
==compares value (calls__eq__).iscompares identity (same object in memory, sameid()).
# Python 3.8+ warns on `is` against an int/str literal, since it's almost always a mistake.
# We're deliberately doing it on purpose here to demonstrate identity semantics, so silence that warning.
import warnings
warnings.filterwarnings("ignore", category=SyntaxWarning)
a = 256
b = 256
print("256 is 256:", a is b, "|", a == b)
e = -5
f = -5
print("-5 is -5: ", e is f, " <- both in the cached range, always True, script or notebook")
256 is 256: True | True -5 is -5: True <- both in the cached range, always True, script or notebook
`==` vs `.equals()`, and the String constant pool
== on objects compares reference identity. .equals() compares logical equality (as defined by
the class). String literals are special: the JVM interns them in a shared constant pool, so two identical
literals ARE the same reference — but new String(...) deliberately creates a distinct heap object even
if the contents match. .intern() looks a string up in (or adds it to) the pool.
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
System.out.println("s1 == s2 (both literals, pooled): " + (s1 == s2));
System.out.println("s1 == s3 (s3 is `new String(...)`): " + (s1 == s3));
System.out.println("s1.equals(s3): " + s1.equals(s3));
System.out.println("s1 == s3.intern(): " + (s1 == s3.intern()));
s1 == s2 (both literals, pooled): true
s1 == s3 (s3 is `new String(...)`): false
s1.equals(s3): true
s1 == s3.intern(): true
Mutating a collection while iterating it
dict/set size cannot change during iteration
Adding or removing keys from a dict (or elements from a set) while iterating over it raises
RuntimeError: dictionary changed size during iteration. CPython's dict iterator tracks a version
counter on the dict; any operation that changes the dict's size bumps that counter and the next
__next__() call notices the mismatch and raises. Note the precise rule: it's about size, not
content — reassigning the value of an existing key is completely fine mid-loop.
d = {"a": 1, "b": 2}
try:
for k in d:
d["c"] = 3 # adds a NEW key -> changes size
except RuntimeError as e:
print("RuntimeError:", e)
print()
d2 = {"a": 1, "b": 2}
for k in d2:
d2[k] = d2[k] * 10 # only changes an EXISTING key's value -> size unchanged, totally fine
print("mutated values in place:", d2)
print()
s = {1, 2, 3}
try:
for x in s:
s.add(4)
except RuntimeError as e:
print("RuntimeError (set):", e)
RuntimeError: dictionary changed size during iteration
mutated values in place: {'a': 10, 'b': 20}
RuntimeError (set): Set changed size during iteration
Fail-fast iterators: `ConcurrentModificationException` — and when it DOESN'T fire
ArrayList's iterator checks a modCount on every next() call and throws ConcurrentModificationException
if the list was structurally modified outside the iterator. The commonly-missed detail: this check only runs
inside next(). If you remove an element such that hasNext() becomes false right after, the loop ends
before next() is called again — so the exception silently doesn't fire, and you get a subtly-wrong
result instead of a loud error. This is real, verified behavior, not a hypothetical.
import java.util.*;
System.out.println("case A: [1,2,3], remove value 2 (second-to-last) mid for-each");
List<Integer> a = new ArrayList<>(List.of(1, 2, 3));
try {
for (Integer v : a) { if (v == 2) a.remove(v); }
System.out.println(" NO exception thrown. list silently ends up = " + a + " <-- the trap");
} catch (ConcurrentModificationException e) {
System.out.println(" CME: " + e);
}
System.out.println("case B: [1,2,3,4], remove value 2 (NOT second-to-last)");
List<Integer> b = new ArrayList<>(List.of(1, 2, 3, 4));
try {
for (Integer v : b) { if (v == 2) b.remove(v); }
System.out.println(" no exception. list now = " + b);
} catch (ConcurrentModificationException e) {
System.out.println(" CME: " + e);
}
case A: [1,2,3], remove value 2 (second-to-last) mid for-each
NO exception thrown. list silently ends up = [1, 3] <-- the trap
case B: [1,2,3,4], remove value 2 (NOT second-to-last)
CME: java.util.ConcurrentModificationException
Lazy evaluation: nothing runs until you pull a value
Generators: lazy, one-shot, and why they save memory
A function with yield in its body is a generator function; calling it doesn't run the body — it returns a
generator object (an iterator) that runs the body lazily, one yield at a time, on each next(). Compare
memory behavior: a list comprehension builds the entire list up front; a generator expression produces
values on demand and never holds more than one at a time.
def countdown(n):
print(f" (starting countdown from {n})")
while n > 0:
yield n
n -= 1
print(" (countdown done)")
gen = countdown(3)
print("generator created, body has NOT run yet:", gen)
print("first next():", next(gen))
print("second next():", next(gen))
print("third next():", next(gen))
try:
next(gen)
except StopIteration:
print("StopIteration once exhausted")
generator created, body has NOT run yet: <generator object countdown at 0x7b18d0d13850> (starting countdown from 3) first next(): 3 second next(): 2 third next(): 1 (countdown done) StopIteration once exhausted
Streams are lazy: nothing runs until a terminal operation
Intermediate operations (filter, map, peek, ...) just build up a pipeline description; none of them
execute a single element until a terminal operation (findFirst, collect, forEach, ...) pulls
values through. Terminal operations can also short-circuit, so a lazily-built pipeline may process far fewer
elements than the source size. Watch the interleaving of print statements below — it proves the laziness,
it doesn't just assert it.
import java.util.*;
import java.util.stream.*;
List<Integer> src = List.of(1, 2, 3, 4, 5);
var stream = src.stream()
.peek(x -> System.out.println(" peek saw " + x))
.filter(x -> x > 2);
System.out.println("stream pipeline built -- nothing printed above, because nothing has RUN yet");
Optional<Integer> first = stream.findFirst(); // terminal op, short-circuits after the first match
System.out.println("first element > 2: " + first + " <- only elements 1..3 were ever peeked at, not all 5");
stream pipeline built -- nothing printed above, because nothing has RUN yet
peek saw 1
peek saw 2
peek saw 3
first element > 2: Optional[3] <- only elements 1..3 were ever peeked at, not all 5
The equality/hash-code contract for hash-based collections
The `__eq__`/`__hash__` contract
If a class defines __eq__, Python sets __hash__ to None automatically unless you also define it --
this is stricter than many languages: the object doesn't silently misbehave in sets/dicts, it becomes
outright unhashable and hash(obj) raises TypeError. This is the language enforcing the contract
("equal objects must have equal hashes") at the point of misuse, rather than letting it corrupt a hash table
silently.
class Bad:
def __init__(self, v):
self.v = v
def __eq__(self, other):
return isinstance(other, Bad) and self.v == other.v
# no __hash__ override
b = Bad(1)
try:
hash(b)
except TypeError as e:
print("hash() on an eq-only class:", e)
print("Bad.__hash__ is:", Bad.__hash__)
try:
{b}
except TypeError as e:
print("can't even put it in a set:", e)
hash() on an eq-only class: unhashable type: 'Bad' Bad.__hash__ is: None can't even put it in a set: unhashable type: 'Bad'
The `hashCode`/`equals` contract
Hash-based collections (HashSet, HashMap) find an entry's bucket using hashCode(), then confirm the
match using equals(). If you override equals() without also overriding hashCode(), two "equal" objects
can land in different buckets — so a HashSet will silently contains() == false for an object it should
logically consider a duplicate. The class below deliberately overrides only equals() to show the break.
import java.util.*;
class Bad {
int v;
Bad(int v) { this.v = v; }
@Override public boolean equals(Object o) { return o instanceof Bad b && b.v == v; }
// no hashCode() override -> falls back to identity hash, contract broken
}
Set<Bad> set = new HashSet<>();
Bad b1 = new Bad(5);
set.add(b1);
System.out.println("contains(new Bad(5)) [equal by value]: " + set.contains(new Bad(5)));
System.out.println("contains(b1) [same reference]: " + set.contains(b1));
contains(new Bad(5)) [equal by value]: false
contains(b1) [same reference]: true
Guaranteed cleanup, even when the body throws
Context managers: `__enter__`/`__exit__`, `contextlib.contextmanager`, `ExitStack`
with obj: calls obj.__enter__(), binds its return value (if as x), always calls obj.__exit__(exc_type, exc_val, exc_tb) on the way out -- including when the body raises. If __exit__ returns a truthy value, the
exception is suppressed instead of propagating; returning None/False (the default) lets it continue.
class Resource:
def __enter__(self):
print(" acquiring")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print(f" releasing (exception type seen: {exc_type})")
return False # don't suppress
with Resource() as r:
print(" using")
try:
with Resource() as r:
raise ValueError("boom")
except ValueError:
print("exception still propagated after __exit__ ran")
acquiring using releasing (exception type seen: None) acquiring releasing (exception type seen: <class 'ValueError'>) exception still propagated after __exit__ ran
try-with-resources, `AutoCloseable`, and suppressed exceptions
Resources declared in the try(...) parens get close() called automatically, in reverse declaration
order, even if the body throws. If close() itself throws while an exception from the body is already
propagating, the close-time exception isn't lost -- it's attached to the primary one as a suppressed
exception, retrievable via getSuppressed(), rather than silently replacing the original.
class NoisyResource implements AutoCloseable {
String name;
NoisyResource(String name) { this.name = name; System.out.println(" open " + name); }
public void close() { System.out.println(" close " + name); }
}
try (var a = new NoisyResource("A"); var b = new NoisyResource("B")) {
System.out.println(" using both");
}
// note the close order below: B, then A -- reverse of declaration order
open A
open B
using both
close B
close A
Boilerplate-free value classes with generated equals/hashCode
Dataclasses: `field(default_factory=...)`, `__post_init__`, `frozen=True`, generated `__eq__`
@dataclass auto-generates __init__, __repr__, and __eq__ from the declared fields. The mutable
default argument trap (topic 02) still applies to dataclass fields -- field(default_factory=list) is the
sanctioned fix, since a bare items: list = [] is a TypeError at class-definition time (dataclasses
detect and reject it outright, rather than letting the shared-mutable-default bug happen silently).
from dataclasses import dataclass, field
@dataclass
class Bucket:
items: list = field(default_factory=list)
b1 = Bucket()
b2 = Bucket()
b1.items.append(1)
print("independent lists, not shared:", b1.items, b2.items)
independent lists, not shared: [1] []
Records: compact constructors, generated equals/hashCode/toString
A record auto-generates a canonical constructor, accessors (x(), not getX()), equals/hashCode
(component-wise), and toString(). A compact constructor (no parameter list) lets you validate/normalize
arguments before they're assigned to the (implicitly final) fields.
record Point(int x, int y) {}
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println("p1.equals(p2) [value equality]: " + p1.equals(p2));
System.out.println("p1 == p2 [reference identity]: " + (p1 == p2));
System.out.println("toString(): " + p1);
System.out.println("accessor (not getX()): " + p1.x() + "," + p1.y());
p1.equals(p2) [value equality]: true
p1 == p2 [reference identity]: false
toString(): Point[x=1, y=2]
accessor (not getX()): 1,2
Structural pattern matching in a control-flow statement
`match`/`case` structural pattern matching (PEP 634)
Beyond simple value matching, case patterns can destructure sequences, mappings, and objects (via
__match_args__), combine alternatives with |, and attach a case ... if <guard>: condition. It's closer
to real pattern matching (Rust/Haskell-style) than a fancy switch -- it binds names as it matches.
def describe(x):
match x:
case 0:
return "zero"
case int() | float() if x < 0:
return "negative number"
case [a, b]:
return f"pair: {a}, {b}"
case [a, *rest]:
return f"list starting with {a}, {len(rest)} more"
case {"type": "point", "x": px, "y": py}:
return f"point dict at {px},{py}"
case str():
return "a string"
case _:
return "something else"
for val in [0, -5, [1, 2], [1, 2, 3, 4], {"type": "point", "x": 1, "y": 2}, "hi", 3.5]:
print(f"{val!r:34} -> {describe(val)}")
0 -> zero
-5 -> negative number
[1, 2] -> pair: 1, 2
[1, 2, 3, 4] -> list starting with 1, 3 more
{'type': 'point', 'x': 1, 'y': 2} -> point dict at 1,2
'hi' -> a string
3.5 -> something else
Switch expressions + pattern matching for switch + `sealed`, working together
A sealed interface declares its complete, closed set of permitted implementations. Combine that with
pattern-matching switch over the sealed type, and the compiler can verify exhaustiveness -- no
default branch needed, and adding a new permitted subtype without updating the switch is a compile error,
not a runtime surprise. Record patterns let a case deconstruct a record's components directly, with an
optional when guard.
class Shapes {
sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Rectangle r -> r.w() * r.h();
// no default -- the compiler knows Circle/Square/Rectangle are the ONLY permitted shapes
};
}
}
for (Shapes.Shape shape : List.of(new Shapes.Circle(2), new Shapes.Square(3), new Shapes.Rectangle(2, 5))) {
System.out.printf("%s area = %.2f%n", shape.getClass().getSimpleName(), Shapes.area(shape));
}
Circle
area =
12.57
Square
area =
9.00
Rectangle
area =
10.00