1

Suppose we have the following hierarchy:

    sealed interface Animal permits Dog, Cat, Bear {}
    record Dog() implements Animal {}
    record Cat() implements Animal {}
    record Bear() implements Animal {}

And we want to create a method which pattern matches over a Class<T extends Animal>.

Why am I prevented from doing this? Is it because of erasure?

So if I do the following it won't work:

    static <T extends Animal> String makeNoisePatternMatch(Class<? extends Animal> cls) {
        return switch(cls) {
            case Dog.class c -> "WOOF";
            case Cat.class c -> "MEOW";
            case Bear.class c -> "ROAR";
        };
    }

But I can still do this (which works):

    static <T extends Animal> String makeNoise(Class<T> cls) {
        if(cls.equals(Dog.class)) {
            return "WOOF";
        } else if(cls.equals(Cat.class)) {
            return "MEOW";
        } else if(cls.equals(Bear.class)) {
            return "ROAR";
        } else {
            throw new IllegalStateException("Unknown state");
        }
    }

IntelliJ actually hints that you should change the above to use switch and then produces a the switch expression above (which does not work).

Again - my question is: Why am I prevented from doing this? Is it because of erasure?

vab2048
  • 1,065
  • 8
  • 21

1 Answers1

2

It has mostly nothing to do with erasure.

Class is not magical in this regard. Class<? extends Animal> is no different from List<? extends Animal> - the JVM does not know that instances of Class represent specifically, well, classes, and that therefore one could feasibly want to pattern-match on it.

Separate from that, generics is a figment of the compiler's imagination: At runtime you can 'break' generics. This:

String x = foo;
System.out.println(x.getClass());

Cannot possibly print anything other than java.lang.String (or throw an NPE if x is null) - because the JVM wouldn't let it happen. Any bytecode that tries to mess this up won't pass class load verification.

However, with generics it doesn't work that way:

List<String> x = foo;
Object first = ((List) x).get(0);
System.out.println(first.getClass());

The weirdness on the second line is just to avoid ClassCastExceptions - the above code can certainly print Integer.class or whatnot: You can get javac to compile code that 'breaks' generics and the JVM doesn't care.

Hence, a method that accepts a Class<? extends Animal> can be called with Integer.class if you ignore enough warnings and write silly code, so in that sense 'erasure' is to blame here, but, the sealed nature of things means there is a silent extra case for dealing with unmatched scenarios (which throws an exception), because you could also recompile Animal.java, adding a 4th animal to the sealed list, and fail to recompile the code that contains makeNoisePatternMatch and you run into the same scenario.

The same principle could be applied there, so, no, erasure really isn't the problem here.

The explanation is much, much simpler!

The things you can pattern-match is a hardcoded list, and 'match on an instance of java.lang.Class isn't on the list.

NB: It would be more correct to write if (cls == Dog.class) - class instances are singletons (that is, a.equals(b) and a == b, if both a and b are instances of j.l.Class, neccessarily always give the same answer, but a.equals(b) makes it slightly more 'hidden' that this is the case).

NB2: Sounds like you should file a bug with intellij.


EDIT:

I thought of something. I think this might work. Switching to Number for easier testing, which isn't sealed (but the sealed nature of this has no bearing on anything, the JLS isn't aware of the notion of sealed when specifically using Class<?>, only for instances themselves.

Class<? extends Number> n = int.class;

String type = switch (n) {
  case Class<?> c && n == int.class -> "primitive int";
  case Class<?> c && n == Integer.class -> "boxed int";
  default -> "unknown";
};
System.out.println(type);

And this works!

But, some unfortunate notes:

  • That default case cannot be avoided; the compiler doesn't know that it would be reasonable to generate it (where the default case throws something) given that the input is sealed. It doesn't know anything about j.l.Class.
  • That Class<?> c && part cannot be avoided either, as far as I know.

This therefore looks.. just like an unwieldy, confusing take on a chain of if/else statements (and wouldn't perform any better either), and therefore I don't recommend doing this.

rzwitserloot
  • 85,357
  • 5
  • 51
  • 72
  • Class instances aren't singletons, they're also defined by the classloader that loaded them which is why you can't really use `==` safely for class comparison except in simple cases. – Kayaman Jun 08 '22 at 12:41
  • They __are__ singletons, @Kayaman. Let's say you have 2 separate instances of `java.lang.Class` floating around that both represent j.l.String, which is, as you say, only possible if you have multiple classloaders. These 2 wouldn't be `a.equals(b)` either - they aren't the same class. They just happen to represent the same FQN, but aren't equal. There is, as I said, no reason to ever use `.equals` to compare classes - if `a.equals(b)` is true, then `a == b` would also be. – rzwitserloot Jun 08 '22 at 13:45
  • @rzwitserloot "The things you can pattern-match is a hardcoded list, and 'match on an instance of java.lang.Class isn't on the list." I'm not sure I understand. Where is this list defined? Does the class need to be sealed to allow for pattern matching? – vab2048 Jun 09 '22 at 08:35
  • 2
    The constructs you can use in a `switch` is hardcoded, in the Java Lang Spec. In terms of simple `switch` statements, the Java Lang Spec v1.0 has 'hardcoded' that you can only switch on int and long. JLS5 added enums, JLS7 added Strings to this list. That's the 'hardcoded' part - you can't switch on, say, a `LocalDate` (at least, in JLS11, for example). But, you can switch on any integer. – rzwitserloot Jun 09 '22 at 10:54
  • 2
    The JLS17 (as preview feature) lists the ability to switch on anything, but then restricts what your 'case' statements look like. `case TypeName varName ->` is allowed and is like an `instanceof`. __There is no such rule for generic equality__, that is, using `switch` as an ersatz `if (x == 1) else if (x == 2)` chain, unless `x` is enum/int/long/string. And that's what you're trying to do here. That's why you can't do that. The JLS lists 'switch on anything but use `case Type varName;` style cases, which is `instanceof` based, or switch on ints/longs/switches/enums. Not 'classes'. – rzwitserloot Jun 09 '22 at 10:56
  • 2
    @vab2048 I have edited the answer and added a switch-based take on how to do this. I don't recommend doing this, but there is a way - read the added section at the end for more. – rzwitserloot Jun 09 '22 at 11:02
  • Thank you for your explanation. I think its a shame the ersatz chain version of the switch is not allowed. I can't see why (as long a a default branch is provided) this restriction is there. – vab2048 Jun 09 '22 at 15:54
  • 1
    @vab2048 same reason I explained in the answer: Once you allow it, you can never take it back. – rzwitserloot Jun 10 '22 at 01:23
  • 1
    This is a wrong use of the term “singleton”. A “singleton” implies that there is at most one instance of a type, but there are plenty of `Class` instances. The correct way to say it, is that a particular `class` is represented by a single, canonical `Class` instance. Further, you can’t switch over `long` values. I also mentioned the possibility to use a guarded pattern for `Class` in [this answer](https://stackoverflow.com/a/70187459/2711488) but it’s worth noting that besides the lack of readability, it also offers no performance advantage; it will become an even less efficient linear search. – Holger Jun 13 '22 at 11:41