This notes assumes basic-level understanding of all concepts covered in the course.
The objective of this notes is to highlight potentially easy-to-miss salient points as well as to reinforce concepts by driving intuition.
To ease confusion, in this document, the variable name ALWAYS matches its CTT. Obviously exams may purposely avoid so to confuse students.
Disclaimer. Obviously these are a list of topics that I personally find less intuitive. If you struggle with intuition for another topic, I cannot help you.
1. Compile Time Checking
At compile time, only the CTT matters. (Regardless of correctness, treat new A() as CTT A.)
- This is the single most important fact / intuition for the foundation of Java’s compile time behaviour.
Example. At first glance, this shouldn’t “work” (compile):
Object o = Integer.valueOf(1);
String s = (String) o;
But intuitively, for consistency sake, the compilability of above should be exactly the same as below:
void foo(Object o) {
String s = (String) o;
}
foo(Integer.valueOf(1));
Ignoring noise, the compilability of the former becomes much clearer:
Object <- (Implicit Cast) Integer
String <- (Explicit Cast) Object
Going off topic, how “strict” the compiler is really depends on the language. Here we effectively treat Java as fully myopic. Thus this compiles as well:
String s = (String) (Object) Integer.valueOf(1);
// <-------------------------> Check 1
// <---------------> Check 2
//<---------------> Check 3 (Implicit)
(Obviously removing (Object) fails the cast.)
2. Explicit Cast
Java tries to catch as many bugs as possible. One such way is by flagging out impossible events. In this context, an explicit cast errors when under absolutely no runtime scenario can the explicit cast pass.
Reminders.
- At runtime, the explicit cast requires RTT to be “valid”, otherwise a
ClassCastExceptionis thrown. - A class can only inherit from one superclass, but multiple interfaces.
Below naturally follows from [1]:
class A {}
interface I {}
void foo(A a, I i) {
a = (A) ""; // ❌ sanity check
a = (A) i; // ✅ hypothetically B extends A implements I
i = (I) a; // ✅ above
}
Caveat. Above “hypothetical” does not hold when A is final, since Java is conservative against final, i.e. the explicit cast will not pass.
final class F {}
f = (F) i; // ❌
i = (I) f; // ❌
As a summary, explicit casts are
- Always permitted when either side is an interface, AND neither are
final - Otherwise, only when
AandBhave a direct relationship (WLOGA<:B)
Implicit casts are only permitted when CTT(RHS) <: CTT(LHS).
3. Dynamic Binding
class A {
void foo(A a) { /* AA */ }
}
class B extends A {
void foo(A a) { /* BA */ }
void foo(B b) { /* BB */ }
}
A a = new B();
B b = new B();
b.foo(a); // (BA) Regular function call
b.foo(b); // (BB) Most specific signature chosen
a.foo(a); // (BA) Regular polymorphism
a.foo(b); // (BA) <-- ***** DYNAMIC BINDING QUIRK *****
This is the most classical example.
For polymorphism to work (despite weird CTTs), under the hood, each object has their own virtual table with methods identified by their method descriptor. Hence the same CTT and method descriptor can invoke different functions.
Following [1], the only information present in compiled code is
load b
load a
invoke `void foo(A)` using a
Analogy. Mail carrier A only accepts large packages, while mail carrier B, a child company of A, additionally accepts small packages. You have an item which can theoretically fit in a small package, but if you are to blindly pick a carrier to send it to, you will have no choice but to wrap it in a large package.
Note. This can be thought of as a consequence of LSP.
4. “Most Specific”
We define a poset on the set of
such that
That is, signature a is related to (“less than”) signature b iff all parameter types in a subtype their corresponding parameter types in b.
Then, the “most specific” signature is simply the smallest element of the poset.
- Notably, it may not exist.
Disclaimer 1. I might be abusing sequence notation on tuples and sets here, but as long as I get the point across.
Disclaimer 2. I am purely using intuition and common sense for this section; I may be entirely wrong.
Related. Type inference chooses the smallest element of the set of all types (recursively) mentioned by the declaration that fit current constraints.
5. Primitives
I didn’t think I would need a whole section for primitives, but here we are!
First, some miscellaneous reminders:
Integerandintare different- Wrapper classes are final
- Primitive arrays are invariant
Math.sqrt(-1)is adouble— Especially for “function purity” questions
Primitive Conversions. “Subtyping” here are called widening vs. narrowing conversions (narrowing requires an explicit cast). Intuitively,
Except things get messy for floating points. Thus Java relaxes the definition such that it is a widening conversion if all values in long <: float.
- In other words, Java only concerns the range and NOT the precision,
Assignments. Recall:
- Wrapper types are final. Conversion has to be done between primitive types first, if necessary.
- Java does auto-(unbox)es ONLY during an implicit cast (i.e. regular assignment).
Altogether, both casts below are necessary:
Long l = 1L;
int i = (int) (long) l;
// ^^^^^^ [2] Cannot cast from Long to int directly
// [1] Cannot convert to Integer first as well
// ^^^^^ Narrowing conversion
But perhaps surprisingly, below requires no cast:
Integer i = 1;
long l = i;
// Integer </: Long
// Since this is implicit, Java attempts auto-unboxing
// int <: long ✅
Literals. Though not exactly accurate, I see them as “polymorphic” during assignment only:
- “Numbers” can be treated as
int(by default),char,shortorbyte(if within range).longrequires anLsuffix. - “Single-Quotes” can be treated as
char(by default),shortorbyte(if within range). - “Decimals” are
doubleby default.floatrequires anFsuffix.
Dynamic Binding. Overall quite similar to assignments, but method selection is now involved.
Essentially, Java ALWAYS prefers widening over auto-(un)boxing. That is, ONLY IF there are no available method signatures to find to for the current type, does Java auto-(un)box and “try again”.
foo(1); // ❌ Compile error
void foo(short s) {} // Not selected; int </: short
/* Java ONLY THEN (after exhausting primitives)
* auto-boxes int to Integer */
void foo(Double d) {} // Not selected; Integer </: Double
6. Type Erasure
Preliminary. Java Generics uses type erasure to maintain backward compatibility.
class G { Number n; } // Pre-Generics
class G<T extends Number> { T n; } // Post-Generics
/* Either API should work on pre-Generics code below: */
Number n = new G().n;
Type erasure in this sense provides an avenue for APIs to remain backward compatible (though obviously not always necessary). Importantly, Java API itself promises to be forever backward compatible, and this is only possible through type erasure.
If you think backward compatibility sucks and this is just a wacky workaround, you’re correct! The remaining subsections highlights the quirks and headaches introduced by type erasure.
6.1. Bridging
Type erasure causes a method signature mismatch during inheritance.
class A<T> { void foo(T t) {} }
class B extends A<String> { @Override void foo(String s) {} }
Post erasure:
class A { void foo(Object o) {} }
class B extends A { @Override void foo(String s) {} }
// ^^^^^^^^^^^^^^^^^^ does not override
This breaks programmer expectations of inheritance (recall dynamic binding). Java’s solution is to insert a synthetic method to “bridge” the expectation.
class A { void foo(Object o) {} }
class B extends A { void foo(String s) {} // no longer overrides
/* SYNTHETIC */ @Override void foo(Object o) { foo((String) o); } }
Note 1. As per common sense, this only exists when there is an expectation to bridge to.
Note 2. Obviously this still raises problems:
class A<T> { void foo(T t) {} }
class B extends A<String> { @Override void foo(String s) {}
void foo(Object o) {} } // ❌
The potential bridge and the overloaded method will have the same signature, yet neither overrides the other (see below). Java throws a compile error. No way around it, programmers have to suck thumb.
- This is similar to the error raised when two methods type erase to the same signature.
Reference. [1]
6.2. Type Erased Overrides
Both below are in fact valid overrides:
class A<T> { public void foo(T t) {} }
class B<T> extends A<T> { @Override public void foo(T t) {} }
class C<T> extends A<T> { @Override public void foo(Object o) {} }
This is yet another manifestation of backward compatibility. Consider a migration scenario:
class A {
/* Pre-Generics */
public void foo(Object o) {}
/* Post-Generics */
public <T> void foo(T t) {}
}
To ease Generics adoption (so that millions of dependencies won’t have to update their signatures), Java decides to still consider below an override of the Post-Generics signature.
class B extends A {
@Override
public void foo(Object o) {}
}
In fact, B has the same type erased signature as A anyway, which is what the compiler would have to accept (all thanks to backward compatibility). In summary, the child’s method signature must match either the parent’s directly, or the (fully) type erased version of it.
- This is called being a subsignature.
Note. This is the motivation behind the error message “neither overrides the other”. Contrast this situation with the subsection above.
6.3. Generic Dynamic Binding
As described by @tqbfjotld:
Type arguments are used to determine whether a function is applicable, but they are ignored when choosing which is the most specific function.
class A {}
class B extends A {}
class G {
void foo(A a) { /* A */ }
<T extends B> void foo(T t) { /* B */ }
<T> void foo(T t) { /* O */ }
}
A a = new A();
B b = new B();
G g = new G();
g.foo(a); // (A) As expected
g.foo(b); // (B) Reasonable considering type erasure
g.<A>foo(a); // (A) <--
g.<A>foo(b); // (A) <-- ***** DYNAMIC BINDING QUIRK *****
g.<B>foo(a); // (A) <--
g.<B>foo(b); // (B) Finally as expected...
Above sort of hints at the rationale. Attempting to resolve via type parameter can cause ambiguities, thus Java centralises resolution post type erasure.
In summary, Java picks the most specific (see section) method signature that still fits the type argument (if specified by invocation, AND if method is parameterized), where, the type is always taken to be the upper bound of the type parameter.
In fact, omitting the type parameter kind of behaves like raw types (i.e. no filter done). This is entirely safe though, do reason through it.
Important Caveat. A la last example in Bridging, the class parameter itself still plays a role in dynamic binding.
class G<T> { void foo(T t) { /* G */ } }
class A extends G<Integer> { void foo(Number n) { /* A */ } }
new A().foo(1); // (G) A inherits foo(Integer t)
7. Exception Control Flow
We define here that a method aborts when encountering either
- A statement / invocation which throws an Exception, that is not caught by the “host” method;
- A return statement.
Two important cascading rules.
- If the method attempts to abort in either the
tryorcatchblocks, short circuit to thefinallyblock before actually aborting. - If the method attempts to abort in the
finallyblock, actually abort, overruling the attempted abort action from (1) if exists.
Example.
try {
return 1/0; // caught by method, so does not abort.
// otherwise (if not caught), behaves identically
// to below
} catch (ArithmeticException e) {
return 2/0; // not caught, as not surrounded by another try.
// run code in finally before aborting
// by throwing ArithmeticException
} finally {
return 123; // aborts, overruling the attempted abortion above
}
The result from this method invocation is the result 123, NOT ArithmeticException.
8. Variable Shadowing
A block can only have one variable declaration, including “block parameters”.
- Recall the distinction between field and variable.
interface Fun { int foo(int x); }
class A {
int x = 0; // Fields here have no effect on variable declaration
int y = 0; // Either way, they are still referenceable via this
int z = 0;
public void bar(int y) { // ✅
int x = 1; // ✅
int x = 2; // ❌ x declared above
int y = 3; // ❌ y declared above
String y = ""; // ❌ Types have no effect
for (int i = 0; i < 5; i++) { // ✅ (New scope)
int x = 4; // ❌ Blocks have no effect
int z = 5; // ✅ (New scope)
}
for (int i = 0; i < 5; i++) { // ✅ (New scope)
int z = 6; // ✅ (New scope)
}
Fun a = x -> x; // ❌ Lambdas have no effect
Fun b = z -> z; // ✅ (Lambdas treated as blocks)
Fun c = z -> z; // ✅ (New scope)
Fun d = new Fun() {
@Override
public int foo(int x) { /* */ } // ✅ <-- ***** SEE NOTE *****
};
Fun e = z -> this.z; // ✅ (New scope)
Fun f = z -> x + this.x; // ✅ Let this sink in
}
}
Note. Java makes an exception by allowing variables declared within a local class to shadow the “environment” variables.
In the example above, the “environment” y is still accessible from the inner method (doing so will capture it), but the “environment” x is completely shadowed.
9. Variable Capture
Java does not have real closures (think Python and Javascript), rather it attempts to emulate one by copying by value. This works for
- Fields, via
thisreference - Variables, only when (effectively) final.
Emulation only breaks when the value is mutated, thus the effectively final restriction.
- What is a “value” should be intuitive, but if not, recall Stack and Heap.
interface I { void foo(); }
class A {
int field = 1;
I bar() {
int x = 2; // must be "effectively final" to be captured
x = 3; // ❌ mutation of captured variable
int arr[] = { 4 };
class B implements I {
public void foo() {
System.out.println(x); // ✅
x = 5; // ❌ mutation of captured variable
arr[0] = 6; // ✅ reference itself not mutated
field = 7; // ✅ `A.this` is not mutated
}
}
x = 8; // ❌ mutation of captured variable
return new B();
}
}
Note. As hinted from the title, fields are not captured directly. Rather the corresponding captured variable is the instance reference (A.this), or for static fields, the “metaspace reference” (A). They obviously cannot be modified.
- For this course, non-static inner classes always capture outer
this.
10. CompletableFuture
For all practical purposes (for CS2030S at least), just treat it like a Promise monad.
Certain methods (namely “chaining” and “exception handling”) return a new CompletableFuture.
- If the “parent” has already completed, the calling thread will be blocked while it eagerly executes the new task.
- Otherwise, the new task will be scheduled (LIFO) on the same thread as the “parent”.
Alternatively, almost all of them have an Async variant. As its name suggests, they do not block anything, and can be scheduled anywhere.
Example. Refer to PS 09 Homework.
/* CF refers to CompletableFuture */
CF<String> a = CF.supplyAsync(() -> { /* ... */ });
// [1]
CF<String> b = a.thenApply(x -> x);
CF<String> c = a.thenApply(x -> x);
- If
ahas completed at [1], thenbandcwill be executed sequentially, blocking the main thread. - Otherwise,
cwill be executed beforebdue to LIFO behaviour, both asynchronous from the main thread.
Disclaimer. This was solely based on observation, AND this seems to be heavily implementation dependent (docs refuses to specify its details). I did not dig too deep into this.
11. Miscellaneous
Just some (potentially tricky) pointers to highlight:
- Uninitialized (
∅) — fields have default values, variables cannot be used String.equalsvs==(default)vsprotectedinstanceofcan raise compile errors a la castingabstracterrors if declaration is un-”overridable”- Default constructors
super/thismust be the first statement in constructor (Java <25)- Array covariance issues
- Annotations work on declarations only
- Exception chains are checked sequentially
- “Immutable up to
T” - Instance method reference signatures
- Streams are mutable
getvsjoin
Annotations covered in this course:
@Override@SuppressWarnings("...")/@SuppressWarnings({"...", ...})uncheckedrawtypes- (Not directly covered)
varargs
@SafeVarargs@FunctionalInterface
Of course Generics deserves its own section
implementskeyword ONLY EVER EVER appears in ONE place<T extends A & B>onlyAcan be a class<&>is only used for Generic declaration<super>is only used with wildcards- PECS
- A producer has a function call of return type T
- A consumer has a function call of argument type T
- “Conservative against
final”- Lazy to explain, but basically
G<? extends A>does not have special treatment whether or notAisfinal
- Lazy to explain, but basically
Additional large standalone documents:
- Type Erasure II (Reifiable Types / Heap Pollution)
- Arrows and Lambdas
- Java
reduceShenanigans ForkJoinPoolIntuitionCompletableFutureAPI Appendix
Finally,
LSP IS ARBITRARY AND ENTIRELY SPEC DEPENDENT.
Literally, find and replace parent with child and check if all “descriptions” still hold.