Unerased throws clause of a method in java

Viewed 91

I can not understand the following part of JLS:

More precisely, suppose that B is a class or interface, and A is a superclass or superinterface of B, and a method declaration m2 in B overrides or hides a method declaration m1 in A. Then:

  • If m2 has a throws clause that mentions any checked exception types, then m1 must have a throws clause, or a compile-time error occurs.

  • For every checked exception type listed in the throws clause of m2, that same exception class or one of its supertypes must occur in the erasure (§4.6) of the throws clause of m1; otherwise, a compile-time error occurs.

  • If the unerased throws clause of m1 does not contain a supertype of each exception type in the throws clause of m2 (adapted, if necessary, to the type parameters of m1), then a compile-time unchecked warning occurs, unless suppressed by @SuppressWarnings

Could somebody explain what does it mean - the unerased throws clause of m1?

1 Answers

You need quite a bit of background to understand what's happening here.

  • "Erasure" is talking about generics. Generics were introduced in java 1.5. The point is - they weren't in java 1.0. That means the generics implementation has loads of bizarre edge cases, because lots of the java ecosystem was designed without them.

  • Both generics as well as checked exceptions are figments of javac. Both of these concepts do not exist, at all at the JVM level. The JVM completely ignores generics, it has absolutely no idea what they are. The same applies to checked exceptions. java (the VM) just doesn't understand them and doesn't have to. They exist in class files solely so that, if you compile some code with javac and there's stuff on the classpath, that javac can read the checked exception list or the generics in a signature and generate appropriate errors, warnings, and apply some syntax sugar. To the JVM, generics and throws clauses are comments - completely ignored by the runtime.

  • Nevertheless javac sometimes flat out refuses to compile something even if, at bytecode/classfile/JVM level, it would be accepted. For example, at the bytecode/JVM level, it is legal for a class to have no parent class. Not even java.lang.Object. However, javac simply won't do this. The only way is to use some other compiler, or to edit the bytes in a class file by hand, or to use a bytecode tool like ASM or BCEL or whatnot. Other times, it WILL compile something even though it makes semantically no sense, and merely emits some warnings to try to protect you from your mistake. But, javac will never (unless javac is bugged) produce code that is flat out invalid (java the VM cannot read the class file, or it can but the verifier rejects it). To contrast this, this code: private String stringField; ... void foo() { stringField = new Object(); }, if you could somehow manage to compile this by hacking javac or hand-wringing a class file, does not load. A ClassVerifierError will be thrown instead.

  • Yes, that means you can write methods that do not decree that they threw e.g. IOException in their throws clause, and yet they DO throw it. Both kotlin and scala do this as part of their langspec (they don't have checked exceptions). Lombok's @SneakyThrows lets you write java code that does it. A few hacks, involving generics (and you've stumbled on one of them here!) can be used to write pure java code that a vanilla javac can compile which also lets you throw checked exceptions without declaring them, however, you'll always get a warning unless you suppress it.

On to your question

Bullet point #1 is obvious and I assume you have no questions about it. Bullet #2 is a case of: At the bytecode/classfile/VM level it's all accepted, but javac flat out refuses to compile code it thinks makes no sense, even though if it did compile it, a JVM would run it.

Bullet #3 is the last case: javac WILL compile code that makes no sense, because the JVM will run it without complaint (but weirdness will ensue; in this case, it would be possible to write code that gets a surprise checked exception: One thrown by a method that does not appear to be declared to throws it), but javac will at least warn you that you're messing up.

The reasoning behind why the 'erased...' bulletpoint (#2) is considered an error and the unerased bulletpoint a warning? I wasn't the designer of the erasure feature but the answer surely is this: If you take a library without generics (for example, because it was written before generics as a feature was introduced in the first place), and then add generics to it, you can end up in a scenario where bullet #3 is relevant. In order to allow library authors to update their code without having to tell their users that the new version is entirely incompatible with the old, they do warnings and not errors. If you somehow end up with code that violates what bullet #2 is talking about, that never made sense. (This in general explains why almost all violations of the type system based on generics end up causing javac to emit a warning and compile it anyway).

What does 'erased' and 'unerased' mean here?

Generics (the stuff in <>, such as List<String>) simply do not exist for many rules, such as when determining the fully qualified name of a method or type. That's because said rule predates generics. Then the spec will talk about first 'erasing' the type and then working with the 'erased type' to apply the rule. At the bytecode (and therefore class file) level, most of the generics are erased entirely, as in, they literally aren't there.

Example: List<? extends Object> x = new ArrayList<String>(); - that <String> part disappears entirely. It is nowhere in the class file and it is impossible to derive it from x. For that matter, assuming that's a local variable and not a field, so is the <? extends Object> part!). You can use javap -c -v to show the bytecode. You'll find no mention of this.

The generics that do exist in class files are all part of signatures: the type of a field, the return type, parameter types, or throws clause of a method, the param types and throws clauses of a constructor, and the extends and implements parts of your class/interface declaration: Those are the signatures. These do exist in class files (and javap will show it to you) but the compiler considers these comments, in the sense that java the VM will completely ignore them. They exist solely for the purpose of reflective code access and for javac, so that it can give you errors and warnings.

Thus, generics exist solely to enable compiler syntax sugar and to generate compiler errors and warnings when you make mistakes. This code:

List<String> x = new ArrayList<String>();
x.add(5.0);

is self-evidently incorrect (you're adding a double to a list of strings), but at the bytecode/VM level, if you can somehow tell javac to shut up and compile this already, it runs fine, because at the bytecode/VM level, generics do not exist, at all. The VM merely sees 'an ArrayList', and you add 'some object' to it, which is fine.

It's javac, by refusing to compile this, that is giving you type safety.

It's rare, but generics are legal in throws clauses. This compiles. Try it!

public class Test {
    public <T extends IOException> foo() throws T {}
}

With that background, let's interpret the fragment of the JLS you pasted:

that same exception class or one of its supertypes must occur in the erasure (§4.6) of the throws clause of m1; otherwise, a compile-time error occurs.

The erasure in this sentence refers to the notion of erasing generics by reducing them to their lower bound. This comes up in various parts of the spec. For example, the bytecode-level full method name of e.g. void foo(String name, int[] data) is foo(Ljava/lang/String;[I)V. Generics can't be put in these names, so if you write: <N extends Number> void bar(N in), the name becomes bar(Ljava/lang/Number;)V. Why? Because Number is the 'erased' version of N here, and we need to erase because these full names (which is how code identifies an actual method in the class file. Again, you should be doing a lot of javap on example code if you actually want to understand any of this stuff!). Why is that? Because java 1.0 didn't support generics. Java 1.5 introduced them in a way that didn't break code written for java 1.0-1.4.

In other words, this sentence is saying, given <T extends IOException> void foo() throws T, the 'erasure (§4.6) of the throws clauseisthrows IOException`.

Thus, that second bullet point is saying that this:

public class Parent {
    <E extends IOException> void foo() throws E {}
}

class Child extends Parent {
    void foo() throws FileNotFoundException {}
}

is, at least as far as bullet 2 is concerned, acceptable. The erased part of m1 here is throws IOException, and IOException is a supertype of m2's FileNotFoundException, thus, this is fine.

Contrast to:

public class Parent {
    <E extends SQLException> void foo() throws E {}
}

class Child extends Parent {
    void foo() throws FileNotFoundException {}
}

this does not compile; the erased throws clause of m1 is throws SQLException. SQLException is neither the same as FileNotFoundException nor a supertype of it, thus, bullet 2 says: Refuse to compile this an error out. Try it. It won't work.

Looking at bullet 3:

If the unerased throws clause of m1 does not contain a supertype of each exception type in the throws clause of m2 (adapted, if necessary, to the type parameters of m1), then a compile-time unchecked warning occurs,

This is talking about trying to do the smart thing. Here, the unerased throws clause is E, which is a type variable. All we know about it is that it is IOException or some subtype, and we have no idea what - every caller of this method gets to pick something. Thus, some caller might decide to make that UnknownHostException and now their code is all bizarro, with a method that resolves out to 'throws UnknownHostException', but it ends up throwing a FileNotFoundException which is bizarre as that is checked and yet it is thrown by a method that did not declare it. As mentioned at the top of this answer, the JVM is fine with it, even if javac isn't. Because your code can potentially now act in such a bizarre fashion, you get a warning for your troubles.

The reason it's a warning and not an error is because it is possible (but rare) that you have a library that predates 1.5 and now that generics are a thing you want to change it from void foo() throws IOException to <E extends IOException> void foo throws E, and the idea behind the generics introduction was that you could adapt existing libraries and give them generics without making them backwards incompatible. Thus, warnings instead of errors here.

This is not likely to come up. Generics are now 25 years old; the amount of libraries or code still in active use out there that isn't fully genericsed is now infinitesemal. Sticking typevars in throws clauses is a rare occurrence. So, it's 'a rare thing' * 'a rare thing' = 'an extremely rare one in a million shot'.

Related