Can I give a generic function the correct type information?

Viewed 95

I have a bunch of class properties that are initialized in a somewhat complicated but very uniform way. Since it is both error prone and a pain to maintain about 10 copies of this code I would like to abstract this.

Here is what the code looks like for setting one property:

if ( userConfigMapPath != null ) {
    final TypeReference<UserConfig> ref =
            new TypeReference<UserConfig>() {
            };
    this.userConfig = Optional.of(
            new GenericConfigMap<>( userConfigMapPath, (int) REFRESH_RATE.getSeconds(), objectMapper, ref ) );
} else {
    this.userConfig = Optional.empty();
}

This is repeated for each property, except with differing property names (in this case userConfig), path (userConfigMapPath) and type (userConfigMap).

So I try abstracting it into a method.

    private <T> Optional<GenericConfigMap<T>> optionalGenericConfigMapFromPath( final ObjectMapper objectMapper, final String path ) {
        Optional<GenericConfigMap<T>> result;
        if ( path != null ) {
            final TypeReference<T> ref =
                    new TypeReference<T>( ) {
                    };
            result = Optional.of(
                    new GenericConfigMap<>( path, (int) REFRESH_RATE.getSeconds( ), objectMapper, ref ) );
        } else {
            result = Optional.empty( );
        }
        return result;
    }

Note that since the type of each property is different this function is generic. I use this method to set each property:

this.userConfig = optionalGenericConfigMapFromPath(objectMapper, userConfigMapPath);
this.networkConfig = optionalGenericConfigMapFromPath(objectMapper, networkConfigMapPath);
this.dodadConfig = optionalGenericConfigMapFromPath(objectMapper, dodadConfigMapPath);

Now this compiles however it has some serious runtime issues. The crux of the issue is that the compiler seems unable to determine what T is in any of the above cases. It seems to base its selection of T based on the default of the object mapper instead of the type of the property. And since the object mapper is reading from json this makes for a LinkedHashMap instead of any type we want.

Now this doesn't cause a compile error, something that baffles me, but instead produces an exception any time we try to use a method from one of these properties we get a runtime type error (could not cast LinkedHashMap to the actual type).

What I want is for the compiler to actually know at compile time the type of T for each call to the function. In fact I would be perfectly happy to tell it the type of T if it would let me. Using this syntax that @JeroenSteenbeeke helped me with

this.userConfig = this.<UserConfig> optionalGenericConfigMapFromPath(objectMapper, userConfigMapPath);
this.networkConfig = this.<NetworkConfig> optionalGenericConfigMapFromPath(objectMapper, networkConfigMapPath);
this.doodadConfig = this.<DoodadConfig> optionalGenericConfigMapFromPath(objectMapper, doodadConfigMapPath);

This type information seems to have just been ignored since when I run the debugger it still behaves exactly as it did. So I tried some janky dependent typing using Class as a proxy:

    private <T> Optional<GenericConfigMap<T>> optionalGenericConfigMapFromPath( final Class<T>, final ObjectMapper objectMapper, final String path ) {
        Optional<GenericConfigMap<T>> result;
        if ( path != null ) {
            final TypeReference<T> ref =
                    new TypeReference<T>( ) {
                    };
            result = Optional.of(
                    new GenericConfigMap<>( path, (int) REFRESH_RATE.getSeconds( ), objectMapper, ref ) );
        } else {
            result = Optional.empty( );
        }
        return result;
    }
this.userConfig = optionalGenericConfigMapFromPath( UserConfig.class, objectMapper, userConfigMapPath);
this.networkConfig = optionalGenericConfigMapFromPath( NetworkConfig.class, objectMapper, networkConfigMapPath);
this.doodadConfig = optionalGenericConfigMapFromPath( DoodadConfig.class, objectMapper, doodadConfigMapPath);

This is basically the dependent function type:

Dependent type

The compiler also ignores the type information given in this example and continues to produce the same incorrectly typed programs as before.

I'm really at my wits end with this. Is there any way to get the compiler to recognize this type information and create values of the correct types?

2 Answers

This isn't a compiler bug at all, it's just a result of how Jackson implements TypeReference (although I suppose it's caused by a limitation of Java).

When you write new TypeReference<T>() {};, the compiler rewrites that to something like new Foo$1(); where Foo is the name of the class your method is in. The generated anonymous class looks something like this. Note that if T is a type parameter to another method, the argument to TypeReference is a special Type that refers back to the original type parameter T.

class Foo$1 extends TypeReference<T> {}

Try it online! and/or look at it in Godbolt.

The TypeReference constructor looks something like this:

protected TypeReference() {
  Type superClass = getClass().getGenericSuperclass();
  //_type is the type to construct. It should be UserConfig.class, DoodadConfig.class, etc.
  _type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
}

So if the T above is a concrete type like UserConfig, everything's hunky-dory. getClass() will give you Foo$1.class, getGenericSuperclass() will give you a type representing TypeReference<UserConfig>, and _type will be UserConfig.class. Later on, when Jackson is reading an object, it sees that you want a UserConfig and not a LinkedHashMap, so it uses reflection to look at UserConfig and construct an object of that class.

However, if T is a type parameter, _type will be a TypeVariable. Unfortunately, this TypeVariable cannot yield any information about what T's actual value was, at least as far as I can tell. This is because there is only one anonymous class that is generated, even if there are multiple instances of it. Since Java doesn't do monomorphization, all the TypeReference<T>s you create in your optionalGenericConfigMapFromPath method will be instances of the same exact class: Foo$1. The superclass of that is TypeReference<T>, which is erased to TypeReference<Object> because it's impossible for that T to point to every single XConfig class you have.

Because Jackson doesn't have any information about which class you want to construct an instance of, it just defaults to using a LinkedHashMap. Since that's obviously not the same as, say, UserConfig, you get a ClassCastException or something of that sort at runtime.

The compiler does infer the types correctly, but even if it didn't, it wouldn't matter because of the wacky way type erasure is handled. Since Jackson makes heavy use of reflection, these sort of weird things are probably more likely to happen. It's best if you pass either a TypeReference<T> or a Class<T> to the optionalGenericConfigMapFromPath method itself. You are already passing a Class, but even if it affected type inference, it's useless for the runtime stuff, so you'll have to give it as an argument to GenericConfigMap instead of ref, assuming that GenericConfigMap's constructor takes any Type, not just TypeReferences.

The reason this fails is that the TypeReference doesn't capture the T at the method's call site, because it can't see the method's call site.

You pass in the Class<T> as a parameter (but I think it is unused, at least, you've not named it, so I can't see where it is used). Instead, pass in the TypeReference<T> at the call site (e.g. new TypeReference<UserConfig>() {}), and don't try to construct it in the method.

Related