I came across a strange issue when I changed some classes in a library from extending an abstract class to inherit an interface instead. This, combined with a generic getter that casts the return value to a subclass of that interface or abstract base class in another area of the code base, ends calling an different overloaded logger method. If the "base type" is an interface it calls the logger method that accepts a Throwable
and leads to a cast exception. But if the "base type" is a class it calls the expected logger method.
It's hard to explain in words, so I've created this minimal test class to illustrate what I'm talking about:
@Test
public static class TestWeirdInterfaceAndClassDifference {
static class BaseClass { }
interface BaseInterface { }
static class ImplClass extends BaseClass implements BaseInterface { }
static class ObjectContainer {
private final ImplClass implClass = new ImplClass();
<T extends BaseClass> T getImplAsBaseClass() {
return (T) implClass;
}
<T extends BaseInterface> T getImplAsBaseInterface() {
return (T) implClass;
}
}
private static class FakeLogger {
void info(String format, Object... args) { }
void info(String message, Throwable throwable) { }
}
static FakeLogger LOGGER = new FakeLogger();
static ObjectContainer CONTAINER = new ObjectContainer();
public void testThatGetAsBaseClassWorks() {
LOGGER.info("Log message {}", CONTAINER.getImplAsBaseClass());
}
public void testThatGetAsInterfaceFails() {
// Throws a ClassCastException :-(
LOGGER.info("Log message {}", CONTAINER.getImplAsBaseInterface());
}
}
The testThatGetAsBaseClassWorks
is fundamentally how the code worked prior to my change, and the FakeLogger#info(String, Object...)
method is called as expected. The testThatGetAsInterfaceFails
then shows how after I changed the base class to be an interface the ObjectContainer#getImplAsBaseInterface
is trying to cast it to Throwable
and the JVM attempts to call the FakeLogger#info(String, Throwable)
method instead. Of course ImplClass
is not a Throwable
which leads to the following exception being thrown: ClassCastException: ImplClass cannot be cast to java.lang.Throwable
. As far as I can tell, the only difference between the working test/codepath and the failing is the first is using a class and the other is using an interface. I know interfaces and abstract classes are not the same thing, but surprised me that they worked differently in this case.
I think the main problem is the generic method (getImplAsBaseClass()
and getImplAsBaseInterface()
in the example above) that casts the return value to whatever is "needed" at the call site, I just assumed that the rules for interfaces and classes would be the same, but they must not be. Unfortunately I can't get rid of the generic method from the actual library code because it's used in many places by clients of this library.
I did find that if I used a local variable to set the type returned by the getter it works fine:
public void testThatGetAsInterfaceFails() {
// Works now!
BaseInterface bi = CONTAINER.getImplAsBaseInterface();
LOGGER.info("Log message {}", bi);
}
I could have all clients of the library change any place where the getter is used inline in a logger statement, but it's quite a few "pointless" changes and that would still leave a "foot-gun" laying around.
So I just decided to revert my change after Google and StackOverflow searches turned up nothing helpful on this topic. Now I just want to understand why interfaces and classes behave differently in this situation.
<T extends BaseInterface>
can be bound to Throwable
. In general, this is hacky generics; the format:
public <T> T someMethod() {
... T is not mentioned, at all, anywhere here ...
}
is an 'auto-casting' hack - where any attempt to invoke this method will silently cast itself to whatever is needed, i.e. - pretty much guarantees ClassCastException
unless you really know what you are doing. These hacks are rarely appropriate; however, if you return null
(which as a literal is every type), this is fine, and, of course, if the method doesn't actually ever return (for example, System.exit
doesn't actually return, and a method might never return normally, instead always exiting by way of throwing something) - then this is fine.
You've got these auto-cast hack methods, but one of them restricts T with <T extends BaseClass>
, the other with <T extends BaseInterface>
.
The crucial difference is that with those restrictions:
BaseClass
cannot silently autocast to Throwable
.BaseInterface
can do that.The reason is that java doesn't allow multiple inheritance. It is not possible to have a java type such that it both [A] is instanceof Throwable
as well as [B] be instanceof BaseClass
.
However, with BaseInterface this is false. A type could exist that is instanceof
both. It is immaterial if such a type actually exists. All that matters is that one could make such a type. And one can:
public class HereItIs extends Throwable implements BaseInterface {}
Given that these are auto-cast hack methods, javac sees 2 possible methods that could work here (The one whose second arg is Object...
and the one whose second arg is Throwable
) and wants to pick the second option because the JLS generally says (as it does here) that the one that needs less magic always wins, and 'wrap the argment into an array to conform to the varargs' is more magical that just passing the argument verbatim. Hence, javac wants to compile it as a call to info(String, Throwable)
and will do so (with an auto-cast) for the BaseInterface
one. It can't do that for the BaseClass
one because the rules say java is not allowed to just pick Throwable
for that T, as Throwable
is not a valid choice for <T extends BaseClass>
, whereas it is a valid choice for <T extends BaseInterface>
, for the reasons stated above.
Hence, in the BaseClass case, javac's rules state that cannot be, and thus javac looks further and then tries to treat it as a call to the info(String, Object...)
variant and realizes it can make that happen. So it does that.
The usual mistake people make when thinking of generics is to treat <T extends BaseInterface>
as 'T represents the type notion: Any object that is a subtype of BaseInterface' and that is wrong. <T extends BaseInterface>
represents 'a type that we do not know, but is real. We call it T. All we know is, it's either BaseInterface, or some type that implements it, possibly even indirectly. In other words, it's precisely about that whole 'wellll, a type COULD exist that implements BaseInterface and extends Throwable'. It's meta-types: T
encompasses all imaginable things that fit the bill, and the code has to make sense for every choice imaginable.