Search code examples
javacasting

ClassCastException vs "Incompatible types" in Java


I've been working on studying for the OCJA8 Java exam and started reading about Exceptions, especially about ClassCastException. I realized I have some trouble in identifying whether it's a good cast, a ClassCastException or a compilation error with the message "incompatible types".

As far as I understood, "incompatible types" compilation error is going to result when trying to cast from a class to an unrelated class (for example, from String to Integer. String isn't neither a subclass, nor a superclass of Integer, so they are unrelated). Such casting does, indeed, result in a compilation error.

Regarding ClassCastException, I'm not sure when it actually happens. Tried reading about it in Boyarsky and Selikoff's OCJA8 book, but still don't have a proper idea of when it happens.

What I know, for sure, is that when I'm trying to cast from a subclass to a superclass, it works. I thought that might happen because the subclass inherits every method/variable of the superclass, so no issues will happen.

I'm still confused about when ClassCastException happens, compared to the "incompatible types" compilation error. Shouldn't this code also result in a runtime exception?

class A {}

class B extends A {}

public class Main {
    public static void main(String[] args) {
        A a = new A();
        B b = a;
    }
}

It doesn't, though. I receive a compilation error. Seems that I don't know when, what happens and can't seem to find it anywhere.


Solution

  • The cast operator looks like this: (Type) expression.

    It used for 3 completely unrelated things, and due to the way java works, effectively, a 4th and 5th thing, though it's not the cast operation itself that causes it, it's merely a side-effect. A real guns and grandmas situation. Just like + in java means 2 entirely unrelated things: Either numeric addition, or string concatenation.

    Hence, you shouldn't ever call it 'casting' unless you mean specifically writing 'parens, type, close parens, expression' which should rarely come up in normal conversation. Instead, call it what the effect of the cast operator actually is, which depends entirely on what you're writing.

    The 5 things are:

    • Primitive conversion. Requires Type to be primitive and expression to also be primitive.
    • Type coercion. Requires Type to be non-primitive and expression to be non-primitive, and is only about the part that is not in <> (so not the generics part).
    • Type assertion. Requires Type to be non-primitive and contain generics, and is specifically about the generics part.
    • Boxing/Unboxing. Java automatically wraps a primitive into its boxed type, or unwraps the value out of a boxed type, as needed, depending on context. casting is one way to create this context.
    • Lambda/MethodRef selection. Lambdas/methodrefs are a compiler error unless, from context, the compiler can figure out what functional interface type the lambda/methodref is an implementation for. Casts are one way to establish this context.

    The space you're currently playing in is the Type Coercion part. Note that neither type coercion nor assertion do any conversion. These do nothing at all at runtime (type assertion), or mostly nothing at all - type coercion, at runtime, either throws ClassCastEx, or does nothing. No conversion ever takes place. This doesn't work:

    Number n = 5;
    String s = (String) n;
    

    One might think this results in the string "5". That's not how casting works.

    What is type coercion

    Type coercion casting does 2 completely separate things:

    • Changes the type of an expression

    In java, when you invoke a method, the compiler must figure out which exact method you mean and codes that into the bytecode. If the compiler can't figure out which one you want, it won't compile. The lookup is based on a combination of the method name as well as the parameter types - specifically, the compile time type of them.

    Number n = 5;
    foo(n); // prints 'Number', not 'Integer'!
    
    void foo(Number n) { System.out.println("Number variant"); }
    void foo(Integer n) { System.out.println("Integer variant"); }
    

    Hence, the type of the expression itself, as the compiler thinks of it, is important for this sort of thing. Casting changes the compile-time type. foo((Integer) n) would print 'Integer variant'.

    • Check if its actually true

    The second thing type coercion does, is generate bytecode that checks the claim. Given:

    Number n = getNumber();
    Integer i = (Integer) n;
    
    Number getNumber() {
      return new Double(5.5); // a double!
    }
    

    Then clearly we can tell: That type cast is not going to work out, n is not, in fact, pointing at an instance of Integer at all. However, at compile time we can't be sure: We'd have to go through the code of getNumber to know, and given the halting problem, it's not possible for arbitrary code to be analysed like this. Even if it was, maybe tomorrow this code changes - signatures are set, but implementations can change.

    Thus, the compiler will just let you write this, but will insert code that checks. This is the CHECKCAST bytecode instruction. That instruction does nothing if the cast holds (the value is indeed pointing at an object of the required type), or, if the object it is pointing at isn't, then a ClassCastException is thrown. Which should probably be called TypeCoercionException instead, and the bytecode should probably be called CHECKTYPE.

    compiler error 'incompatible types' vs ClassCastEx

    A type coercion cast comes in 3 flavours. That 'change the compile time type of the expression' thing is common to all 3. But about the check if it's actually true thing, you have 3 options:

    • It is always true

    This seems pointless:

    Integer i = 5;
    Number n = (Number) i;
    

    And it is - any linting tool worth its salt will point out this cast does absolutely nothing at all. The compiler knows it does nothing (all integers are also numbers, doing a runtime check is useless), and doesn't even generate the CHECKCAST bytecode. However, sometimes you do this solely for the fact that the type changes:

    Integer i = 5;
    foo((Number) i); // would print 'Number variant', even though its an integer.
    

    Point is, this cast, while usually pointless, is technically legal; java just lets it happen and doesn't even generate the CHECKCAST. It cannot possibly throw anything at runtime.

    • It is always false
    Integer i = 5;
    Double d = (Double) i;
    

    At compile time the compiler already knows this is never going to work. No type exists that it both Integer and Double. Technically, null would work, but nevertheless the java spec dictates that the compiler must reject this code, and fail with a 'incompatible types' compiler error. There are other ways to make the compiler emit this error message; this is just one of them.

    • The check may be true or false

    In which case the compiler compiles it and adds a CHECKCAST bytecode instruction so that at runtime the type is checked. This could result in a ClassCastException.

    The other way to get CCEx

    generics are entirely a compile time affair. The runtime has no idea what they mean. That means that this code:

    List<String> list = getListOfStrings();
    list.get(0).toLowerCase();
    

    is compiled to:

    List list = getListOfStrings();
    ((String) list.get(0)).toLowerCase();
    

    The compiler injects a cast (and as the generics-erased List's get method returns Object, the test could pass, or fail, a CHECKCAST bytecode instruction is generated, which could throw ClassCastEx). This means you can cast ClassCastExceptions on lines with no casts, but then it does mean someone messed up their generics and ignored a compile time warning. This method would do the job:

    public List<String> getListOfStrings() {
      var broken = new ArrayList<Number>();
      broken.add(5); // not a string
      List raw = broken; // raw type.
      return (List<String>) raw;
    }