Java generic erasing after Java 8

Viewed 68

I know that Java generic type information is erased at runtime. I have sort-of-abused this to put "wrongly typed" objects into maps to mark mapping to nulls:

import java.util.*;

public class test
{
    private static final Object NULL = new Object();

    public static void main(String[] args) throws Exception
    {
        Map<String, String> kek = new HashMap<>();
        kek.put("foo", maskNull(null));
    }

    @SuppressWarnings ("unchecked")
    public static <T> T maskNull(T value)
    {
        return value != null ? value : (T) NULL;
    }
}

Of course in real code I would use sth. like maskNull(foo) where foo could be anything, so I wouldn't know if it was null or not in advance. Functions like isMaskedNull() etc. are omitted for brevity.

This used to work fine in Java 8. However, now that I'm trying to upgrade to Java 11, I get the following error at runtime:

Exception in thread "main" java.lang.ClassCastException: class java.lang.Object cannot be cast to class java.lang.String (java.lang.Object and java.lang.String are in module java.base of loader 'bootstrap')
        at test.main(test.java:10)

As I understand, Java compiler now implicitly emits type-casting operation before put() is called. Is there a way out of it, i.e. can I get the old behavior at least in specifically marked (I don't know, with an annotation?) cases?

1 Answers

At least as on Java 11.0.15, you can still force your NULL value into the map if you pull the same trick you did in maskNull() to bypass the checking around put(). However, you'll just get an error somewhere else. The point is: Java will assume that you have not stored the wrong type of object, but will sometimes check this assumption. You can't assume once you "get away with it" once it won't be checked later; and you can't easily predict from one version of javac to another when it might be checked. So you should find a different way of achieving your perfomance (and as always with performance, test, don't guess.)

But yes, if you define:

@SuppressWarnings("unchecked")
public static <K, V> void badPut(Map<K, V> m, K key, V value) {
  m.put(key, maskNull(value));
}

Then this "works":

Map<String, String> m = new HashMap<>();
badPut(m, "test", null);
System.out.println(m.get("test") == NULL);

as does this:

Object o = m.get("test");
System.out.println(o);

However, this doesn't work, it compiles but throws a ClassCastException at runtime:

String value = m.get("test");
if (value == NULL) { ... }

And it errors on the first line: you can't get() your NULL into a String. Each of these also throw the exception:

System.out.println(m.get("test"));

String masked = maskNull(null);

So it seems to me you can't have your cake and eat it, too: you can't treat your values as Strings and have some of them not be strings, unless you treat the map like a Map<String, Object> and then cast the value to String. Of course, you could just declare your map that way to begin with and avoid these unpredictable exceptions.

It's a shame there isn't a public method like Map.Entry<K, V> getEntry(key) on HashMap, as there is a package-private one, getNode, that appears to me to not create any new objects. That would give you what you want, a way to do one key lookup, and then test for existence and value without triggering another lookup. Maybe you could copy the source for HashMap into your project and add the public method yourself. Or you could look into Apache Commons collections, which appear to have their getEntry method protected, which means you could subclass and use it. (However, they mention getEntry as not being the most efficient way to access the map.)

And as Petr Janeček said, if these are String values, you could solve this pretty easily with getOrDefault:

static final String MISSING = "<something you are certain won't clash>"

String value = map.getOrDefault(key, MISSING);
if (value == MISSING) {
  ...
} else if (value == null) {
  ...
} else {
  // use value
}
Related