Mastery

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

Python

`is` vs `==`, the small-int cache, and string interning

  • == compares value (calls __eq__).
  • is compares identity (same object in memory, same id()).
python
# 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")
Output
256 is 256: True | True
-5 is -5:  True   <- both in the cached range, always True, script or notebook
Read the full topic →
Java

`==` 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.

java
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()));
Output
s1 == s2 (both literals, pooled):      true
Output
s1 == s3 (s3 is `new String(...)`):    false
Output
s1.equals(s3):                         true
Output
s1 == s3.intern():                     true
Read the full topic →

Mutating a collection while iterating it

Python

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.

python
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)
Output
RuntimeError: dictionary changed size during iteration

mutated values in place: {'a': 10, 'b': 20}

RuntimeError (set): Set changed size during iteration
Read the full topic →
Java

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.

java
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);
}
Output
case A: [1,2,3], remove value 2 (second-to-last) mid for-each
Output
  NO exception thrown. list silently ends up = [1, 3]  <-- the trap
Output
case B: [1,2,3,4], remove value 2 (NOT second-to-last)
Output
  CME: java.util.ConcurrentModificationException
Read the full topic →

Lazy evaluation: nothing runs until you pull a value

Python

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.

python
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")
Output
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
Read the full topic →
Java

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.

java
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");
Output
stream pipeline built -- nothing printed above, because nothing has RUN yet
Output
  peek saw 1
Output
  peek saw 2
Output
  peek saw 3
Output
first element > 2: Optional[3]  <- only elements 1..3 were ever peeked at, not all 5
Read the full topic →

The equality/hash-code contract for hash-based collections

Python

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.

python
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)
Output
hash() on an eq-only class: unhashable type: 'Bad'
Bad.__hash__ is: None
can't even put it in a set: unhashable type: 'Bad'
Read the full topic →
Java

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.

java
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));
Output
contains(new Bad(5)) [equal by value]: false
Output
contains(b1) [same reference]:         true
Read the full topic →

Guaranteed cleanup, even when the body throws

Python

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.

python
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")
Output
  acquiring
  using
  releasing (exception type seen: None)
  acquiring
  releasing (exception type seen: <class 'ValueError'>)
exception still propagated after __exit__ ran
Read the full topic →
Java

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.

java
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
Output
  open A
Output
  open B
Output
  using both
Output
  close B
Output
  close A
Read the full topic →

Boilerplate-free value classes with generated equals/hashCode

Python

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).

python
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)
Output
independent lists, not shared: [1] []
Read the full topic →
Java

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.

java
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());
Output
p1.equals(p2) [value equality]: true
Output
p1 == p2 [reference identity]:  false
Output
toString():                     Point[x=1, y=2]
Output
accessor (not getX()):           1,2
Read the full topic →

Structural pattern matching in a control-flow statement

Python

`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.

python
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)}")
Output
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
Read the full topic →
Java

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.

java
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));
}
Output
Circle
Output
 area = 
Output
12.57
Output

Output
Square
Output
 area = 
Output
9.00
Output

Output
Rectangle
Output
 area = 
Output
10.00
Output

Read the full topic →