CS2030S Supplementary Notes

notes  •  uni  •  java
Cewau  │  Published: 2025-11-20

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.

  1. At runtime, the explicit cast requires RTT to be “valid”, otherwise a ClassCastException is thrown.
  2. 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 A and B have a direct relationship (WLOG A <: 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 (n-ary) method signatures that currently “satisfy requirements” (see Generic Dynamic Binding)

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:

  • Integer and int are different
  • Wrapper classes are final
  • Primitive arrays are invariant
  • Math.sqrt(-1) is a double — Especially for “function purity” questions

Primitive Conversions. “Subtyping” here are called widening vs. narrowing conversions (narrowing requires an explicit cast). Intuitively, primitive types (“sets”) ,

Except things get messy for floating points. Thus Java relaxes the definition such that it is a widening conversion if all values in are approximable in . Hence long <: float.

  • In other words, Java only concerns the range and NOT the precision,

Assignments. Recall:

  1. Wrapper types are final. Conversion has to be done between primitive types first, if necessary.
  2. 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, short or byte (if within range). long requires an L suffix.
  • “Single-Quotes” can be treated as char (by default), short or byte (if within range).
  • “Decimals” are double by default. float requires an F suffix.

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.

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.

  1. If the method attempts to abort in either the try or catch blocks, short circuit to the finally block before actually aborting.
  2. If the method attempts to abort in the finally block, 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 this reference
  • 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 a has completed at [1], then b and c will be executed sequentially, blocking the main thread.
  • Otherwise, c will be executed before b due 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.equals vs ==
  • (default) vs protected
  • instanceof can raise compile errors a la casting
  • abstract errors if declaration is un-”overridable”
  • Default constructors
  • super / this must 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
  • get vs join

Annotations covered in this course:

  • @Override
  • @SuppressWarnings("...") / @SuppressWarnings({"...", ...})
    • unchecked
    • rawtypes
    • (Not directly covered) varargs
  • @SafeVarargs
  • @FunctionalInterface

Of course Generics deserves its own section

  • implements keyword ONLY EVER EVER appears in ONE place
  • <T extends A & B> only A can 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 not A is final

Additional large standalone documents:

  1. Type Erasure II (Reifiable Types / Heap Pollution)
  2. Arrows and Lambdas
  3. Java reduce Shenanigans
  4. ForkJoinPool Intuition
  5. CompletableFuture API Appendix

Finally,

LSP IS ARBITRARY AND ENTIRELY SPEC DEPENDENT.

Literally, find and replace parent with child and check if all “descriptions” still hold.