Search code examples
javagenericsdtotypesafenested-generics

Java Typesafe maps - TypeCheck for both generic types possible?


I am creating a library for Typesafe maps converting from and to POJO, holding data against keys with goal of providing type safety. Key are not arbitrary but fixed constants like enum. I have checked this and this but no solution for checking 2 conditions mentioned below.

I have 2 classes Map and Key. Map has get method which returns value for given Key. There are 2 conditions:

  • A: Key should match Map's generic type.
  • B: get should return value matching Key's generic type.

I want both conditions to be checked at compile time. I don't want to pass redundant Class or type parameter.

class Map<K extends Key<?>{
    <T> T get1(K key) {...} //My first attempt: Solves A(checks key type) but not B(return type T)
    <T> T get2(Key<T> key) {...} //Another attempt: Solves B(checks return type T) but not A(key type)

    // I want to write method get, which checks both conditions something like this:
    <T,K1 extends K & Key<T>> T get(K1 key){...}//Won't compile ofcourse
}
interface/*or abstract class*/ Key<T>{
  //We could use enum instead of subclassing Key, but java enum constants are not generic. See: http://openjdk.java.net/jeps/301
}

This might look complex, but below client code shows it is trivial to use.

//Client Code
class PersonKey<T> extends Key<T>{
  PersonKey<String> name=new PersonKey<>();
  PersonKey<Integer> age=new PersonKey<>();
}
class HouseKey<T> extends Key<T>{
  HouseKey<String> name=new HouseKey<>();
  HouseKey<String> address=new HouseKey<>();
}
//Usage:
Map<PersonKey<?>> person=new Map<>();
String name=person.get(PersonKey.name);//Intended use
Integer age=person.get(PersonKey.age);//Intended use
String name=person.get(HouseKey.name);//A: This should generate compile error: Arg must be PersonKey, not HouseKey
Integer age=person.get(PersonKey.name);//B: This should generate compile error: must return String, not Integer

get1 solves A, get2 solves B, but I could not find a way to check both.

There is an unpractical way to just theoretically achieve this using 2 identical arguments, key1 checking A, key2 checking B:

class Map<K extends Key<?>>{
    <T> T strangeGet(K key1,Key<T> key2) {assert key1==key2;  ... }
}
//Client Use as follows:
String name=person.get(HouseKey.name,HouseKey.name);//A achieved
Integer age=person.get(PersonKey.name,PersonKey.name);//B achieved

Solution so far:

I found a solution, but it is awkward and generates raw-type warnings at Client: Let Key have 2 Types T and ActualKey.

class Map<K extends Key<?,?>>{
    <T> T get(Key<T, K> key) {...}
}
class Key<T,ActualKey extends Key<?,?>> {...}
//Client Code
class PersonKey<T> extends Key<T,PersonKey> {//raw-type warning
    PersonKey<String> name=new PersonKey<>();
    PersonKey<Integer> age=new PersonKey<>();
}
class HouseKey<T> extends Key<T,HouseKey>{//raw-type warning
    HouseKey<String> name=new HouseKey<>();
    HouseKey<String> address=new HouseKey<>();
}
//Usage:
Map<PersonKey> person=new Map<>();//raw-type warning
String name=person.get(PersonKey.name);//Intended use
Integer age=person.get(PersonKey.age);//Intended use
String name=person.get(HouseKey.name);//A: Successfully generates compile error: Arg must be PersonKey, not HouseKey
Integer age=person.get(PersonKey.name);//B: Successfully generates compile error: must return String, not Integer

There seems no way to remove this raw-type warnings in client code.


Solution

  • EDIT:

    I deleted the previous content of the answer because was wrong.

    I found a solution that may satisfy your needs, which is completly type safe and avoids raw types, but produces a more complex declaration of the keys. The usage of the Maps will be simple as you required.

    First of all define two different Key interfaces: The first declaration accept a generic type which will not be the type of the value of the key but the type of the key itself

    public interface Key<T extends Key<T>> {
    
    }
    

    The second declaration defined a Key with a type:

    public interface TypedKey<T, K extends Key<K>> extends Key<K> {
    
    }
    

    now we can define the Map this way:

    public class Map<K extends Key<K>> {
    
        <T, K1 extends TypedKey<T, K>> T get(K1 key) {
            return null; // TODO implementation
        }
    
    }
    

    this need the type of an implementation of Key at instantiation time, and each time you invoke the get method a TypedKey is required which as the same Key as the second generic type.

    With this simple test class you can see the result:

    public class Tester {
    
        static final class PersonKey implements Key<PersonKey> {
            private PersonKey() {}
        }
    
        static final class HouseKey implements Key<HouseKey> {
            private HouseKey() {}
        }
    
        static final class WrongKey implements Key<PersonKey> {
            private WrongKey() {}
        }
    
        static class ExtendableKey implements Key<ExtendableKey> {
    
        }
    
        static class ExtensionKey extends ExtendableKey {
    
        }
    
        static class PersonTypedKey<T> implements TypedKey<T, PersonKey> {
    
        }
    
        static class HouseTypedKey<T> implements TypedKey<T, HouseKey> {
    
        }
    
        /*static class ExtensionTypedKey<T> implements TypedKey<T, ExtensionKey> { // wrong type
    
        }
    
        static class WrongTypedKey<T> implements TypedKey<T, WrongKey> { // wrong type
    
        }*/
    
        public static void main(String[] args) {
            Map<PersonKey> personMap = new Map<>();
            Map<HouseKey> houseMap = new Map<>();
            //Map<WrongKey> wrongMap = new Map<>(); // wrong type
            //Map<ExtensionKey> extMap = new Map<>(); // wrong type
            PersonTypedKey<String> name = new PersonTypedKey<>();
            PersonTypedKey<Integer> age = new PersonTypedKey<>();
            HouseTypedKey<String> houseName = new HouseTypedKey<>();
            String nameString = personMap.get(name);
            Integer ageInt = personMap.get(age);
            //String houseString = personMap.get(houseName); // wrong type
            //ageInt = personMap.get(name); wrong type
            Map<ExtendableKey> extMap = new Map<>();
    
            whatMayBeWrongWithThis();
        }
    
        static class OtherPersonTypedKey<T> implements TypedKey<T, PersonKey> {
    
        }
    
        static class ExtendedPersonTypedKey<T> extends PersonTypedKey<T> {
    
        }
    
        public static void whatMayBeWrongWithThis() {
            Map<PersonKey> map = new Map<>();
            String val1 = map.get(new OtherPersonTypedKey<String>());
            String val2 = map.get(new ExtendedPersonTypedKey<String>());
            /*
             * TypedKey inheritance can be disallowed be declaring the class final, OtherPersonTypedKey can not be disallowed
             * with those declarations
             */
        }
    
        // if needed you can allow Key inheritance by declaring key, typedKey and map with extends Key<? super K>
    }
    

    I also commented some example code that doesn't compile. In the code above you can see that I defined, following your example, PersonKey and HouseKey, and then i defined their typed version which are the implementation that you will actually use. I defined PersonKey and HouseKey final to prevent extension (i added a comment explaining how to add inheritance support) and a private constructor each to prevent instantiation (which can be removed if not needed). There is also a method called whatMayBeWrongWithThis which explains why this solution may not be really the one you need.

    But i have found a solution also for those problems:

    First we have to change a bit the definition of TypedKey and the get method in Map:

    public interface TypedKey<T, K extends TypedKey<T, K>> {
    
    }
    
    public class Map<K extends Key<K>> {
    
        <T, K1 extends TypedKey<T, ? extends K>> T get(K1 key) {
            return null; // TODO implementation
        }
    
    }
    

    now TypedKey doesn't extends Key and the second generic type must be an extension of TypedKey itself. So the get method is more restrictive allowing only TypedKeys that extends K.

    We must also change the definition of PersonKey and HouseKey, but to prevent unwanted extensions of our Keys we have to define them as inner classes, in this way:

    public class PersonKeyWrapper {
    
        public static class PersonKey implements Key<PersonKey> {
            private PersonKey() {}
        }
    
        public static class PersonTypedKey<T> extends PersonKey implements TypedKey<T, PersonTypedKey<T>> {
    
        }
    
    }
    
    public class HouseKeyWrapper {
    
        public static class HouseKey implements Key<HouseKey> {
            private HouseKey() {}
        }
    
        public static class HouseTypedKey<T> extends HouseKey implements TypedKey<T, HouseTypedKey<T>> {
    
        }
    
    }
    

    so PersonKey and HouseKey can be seen from the outside but not extended because of the private constructor, so the only possibile extensions are the TypedKeys that we provide.

    Here an example use code:

    public class Tester {
    
        /*static class PersonTypedKey<T> implements TypedKey<T, PersonKey> { // no more allowed
    
        }
    
        static class HouseTypedKey<T> implements TypedKey<T, HouseKey> { // no more allowed
    
        }*/
    
        public static void main(String[] args) {
            Map<PersonKey> personMap = new Map<>();
            Map<HouseKey> houseMap = new Map<>();
            PersonTypedKey<String> name = new PersonTypedKey<>();
            PersonTypedKey<Integer> age = new PersonTypedKey<>();
            HouseTypedKey<String> houseName = new HouseTypedKey<>();
            String nameString = personMap.get(name);
            Integer ageInt = personMap.get(age);
            //String houseString = personMap.get(houseName); // wrong type
            //ageInt = personMap.get(name); wrong type
    
            whatMayBeWrongWithThis();
        }
    
        /*static class OtherPersonTypedKey<T> implements TypedKey<T, PersonKey> { no more allowed
    
        }*/
    
        static class ExtendedPersonTypedKey<T> extends PersonTypedKey<T> { // allowed, you can declare PersonTypedKey final if you don't wont't to allow this
    
        }
    
        static class OtherPersonTypedKey<T> implements TypedKey<T, PersonTypedKey<T>> {
    
        }
    
        public static void whatMayBeWrongWithThis() {
            Map<PersonKey> map = new Map<>();
            String val1 = map.get(new OtherPersonTypedKey<String>());
            String val2 = map.get(new ExtendedPersonTypedKey<String>());
            /*
             * OtherPersonTypedKey can not be disallowed with this declaration of PersonTypedKey
             */
        }
    }
    

    As you can see from the example some previous unwanted behavior now is no more allowed but we still have problems.

    Here is the final solution:

    The only changes that you need are in the Wrapper classes, turning them into factories of TypedKeys:

    public class PersonKeyWrapper {
    
        public static class PersonKey implements Key<PersonKey> {
            private PersonKey() {
            }
        }
    
        private static class PersonTypedKey<T> extends PersonKey implements TypedKey<T, PersonTypedKey<T>> {
    
        }
    
        public static <T> TypedKey<T, ? extends PersonKey> get() {
            return new PersonTypedKey<T>();
        }
    
    }
    
    public class HouseKeyWrapper {
    
        public static class HouseKey implements Key<HouseKey> {
            private HouseKey() {}
        }
    
        private static class HouseTypedKey<T> extends HouseKey implements TypedKey<T, HouseTypedKey<T>> {
    
        }
    
        public static <T> TypedKey<T, ? extends HouseKey> get() {
            return new HouseTypedKey<T>();
        }
    
    }
    

    now the only extensions of PersonKey and HouseKey are hidden (private modifier) and the only way to get an instance of them is through the factory methods.

    Now the test class:

    public class Tester {
    
        public static void main(String[] args) {
            Map<PersonKey> personMap = new Map<>();
            Map<HouseKey> houseMap = new Map<>();
            TypedKey<String, ? extends PersonKey> name = PersonKeyWrapper.get();
            TypedKey<Integer, ? extends PersonKey> age = PersonKeyWrapper.get();
            TypedKey<String, ? extends HouseKey> houseName = HouseKeyWrapper.get();
            String nameString = personMap.get(name);
            Integer ageInt = personMap.get(age);
            //String houseString = personMap.get(houseName); // wrong type
            //ageInt = personMap.get(name); wrong type
        }
    
        /*static class ExtendedPersonTypedKey<T> extends PersonTypedKey<T> { // no more allowed
    
        }
    
        static class OtherPersonTypedKey<T> implements TypedKey<T, PersonTypedKey<T>> { // no more allowed
    
        }*/
    }
    

    as you can see we have binded the Map a too a really specific type, obtainable only through the Wrapper classes. If you want to create a hierarchy on compatible TypedKeys the only restriction is that you have to declare them as private inner classes inside the Wrapper and expose them through factory methods.