Map with multiple value types with advantages of generics

13,658

Solution 1

You are messing with generics and overloading in a bad way. You are extending HashMap<AbstractKey, Object> and so your class is inheriting the method Object put(AbstractKey k, Object v). In your class you are defining another put method with a different signature, which means you are just overloading the put method, instead of overriding it.

When you write map.put(StringKey.A, 10), the compiler tries to find a method that conforms to the argument types put(StringKey, Integer). Your method's signature doesn't apply, but the inherited put's does -- StringKey is compatible with AbstractKey and Integer is compatible with Object. So it compiles that code as a call to HashMap.put.

A way to fix this: rename put to some custom name, like typedPut.

BTW talking from experience your approach is very fun and engaging, but in real life it just isn't worth the trouble.

Solution 2

Item 29: Consider typesafe heterogeneous containers.Joshua Bloch, Effective Java, Second Edition, Chapter 5: Generics.

Solution 3

IMHO, every problem comes from the original design smell: wanting to put values of different types into the map. I would wrap your Integer and String values into a common Value type instead. Something like this:

public class Value {
    private enum Type {
        STRING, INTEGER;
    }

    private Type type;
    private Object value;

    private Value(Object value, Type type) {
        this.value = value;
        this.type = type;
    }

    public static Value fromString(String s) {
        return new Value(s, Type.STRING);
    }

    public static Value fromInteger(Integer i) {
        return new Value(i, Type.INTEGER);
    }

    public Type getType() {
        return this.type;
    }

    public String getStringValue() {
        return (String) value;
    }

    public Integer getIntegerValue() {
        return (Integer) value;
    }

    // equals, hashCode
}

This way, you just need a Map<SomeKey, Value>, and you can safely get the value from the map:

Value v = map.get(someKey);
if (v.getType() == Type.STRING) {
    String s = v.getStringValue();
}
else if (v.getType() == Type.INTEGER) {
    Integer i = v.getIntegerValue();
}
Share:
13,658
amaidment
Author by

amaidment

Updated on June 23, 2022

Comments

  • amaidment
    amaidment almost 2 years

    I want to create a map that will provide the benefits of generics, whilst supporting multiple different types of values. I consider the following to be the two key advantages of generic collections:

    • compile time warnings on putting wrong things into the collection
    • no need to cast when getting things out of collections

    So what I want is a map:

    • which supports multiple value objects,
    • checks values put into the map (preferably at compile-time)
    • knows what object values are when getting from the map.

    The base case, using generics, is:

    Map<MyKey, Object> map = new HashMap<MyKey, Object>();
    // No type checking on put();
    map.put(MyKey.A, "A");  
    map.put(MyKey.B, 10);
    // Need to cast from get();
    Object a = map.get(MyKey.A); 
    String aStr = (String) map.get(MyKey.A);
    

    I've found a way to resolve the second issue, by creating an AbstractKey, which is generified by the class of values associated with this key:

    public interface AbstractKey<K> {
    }
    public enum StringKey implements AbstractKey<String>{
      A,B;  
    }
    public enum IntegerKey implements AbstractKey<Integer>{
      C,D;
    }
    

    I can then create a TypedMap, and override the put() and get() methods:

    public class TypedMap extends HashMap<AbstractKey, Object> {
      public <K> K put(AbstractKey<K> key, K value) {
        return (K) super.put(key, value);
      }
      public <K> K get(AbstractKey<K> key){
        return (K) super.get(key);
      }
    }
    

    This allows the following:

    TypedMap map = new TypedMap();
    map.put(StringKey.A, "A");
    String a = map.get(StringKey.A);
    

    However, I don't get any compile errors if I put in the wrong value for the key. Instead, I get a runtime ClassCastException on get().

    map.put(StringKey.A, 10); // why doesn't this cause a compile error?
    String a = map.get(StringKey.A); // throws a ClassCastException
    

    It would be ideal if this .put() could give a compile error. As a current second best, I can get the runtime ClassCastException to be thrown in the put() method.

    // adding this method to the AbstractKey interface:
    public Class getValueClass();
    
    // for example, in the StringKey enum implementation:
    public Class getValueClass(){
      return String.class;
    }
    
    // and override the put() method in TypedMap:
    public <K> K put(AbstractKey<K> key, K value){
      Object v = key.getValueClass().cast(value);
      return (K) super.put(key, v);
    }
    

    Now, the ClassCastException is thrown when put into the map, as follows. This is preferable, as it allows easier/faster debugging to identify where an incorrect key/value combination has been put into the TypedMap.

    map.put(StringKey.A, 10); // now throws a ClassCastException
    

    So, I'd like to know:

    • Why doesn't map.put(StringKey.A, 10) cause a compile error?
    • How could I adapt this design to get meaningful compile errors on put, where the value is not of the associated generic type of the key?

    • Is this is a suitable design to achieve what I want (see top)? (Any other thoughts/comments/warnings would also be appreciated...)

    • Are there alternative designs that I could use to achieve what I want?

    EDIT - clarifications:

    • If you think this is a bad design - can you explain why?
    • I've used String and Integer as example value types - in reality I have a multitude of different Key / value type pairs that I would like to be able to use. I want to use these in a single map - that's the objective.
  • amaidment
    amaidment almost 12 years
    You describe this as an 'original design smell' - can you explain what you think is wrong with the design?
  • amaidment
    amaidment almost 12 years
    No - the return value of my put() method is K. This conforms with the inherited method public V put(K key, V value).
  • Marko Topolnik
    Marko Topolnik almost 12 years
    I confused put with Set.add. But the main point is still the same.
  • JB Nizet
    JB Nizet almost 12 years
    Putting values which are of different types in the map means that you have to use instanceof on the values to do something with those values. A more OO way of doing would be to have a common interface or superclass to all the possible values in the map, and use this common interface or superclass as the map's value type. If this ain't possible, it probably means that you should use several maps.
  • maress
    maress almost 12 years
    you put() method does not inherit from Map.put() The compiler sees two methods here: Map.put(AbstractKey key, Object obj) and TypedMap.put(AbstractKey<K> key, K value). Try you implementation on netbeans, it will not show you the override hint annotation
  • Marko Topolnik
    Marko Topolnik almost 12 years
    I see. You think your method does override the superclass method? Annotate it with @Override to check. You are extending HashMap<AbstractKey, Object> and defining <K> K put(AbstractKey<K> key, K value). This is not the same method signature as Object put(AbstractKey key, Object value).
  • amaidment
    amaidment almost 12 years
    Also - I don't think this solution really helps. All you've done is found an alternative way to determine the type of the value from the map. I would even suggest that this is inferior to my approach, as you have to know what Type a Value will have so you can then cast to it. I don't see that this does anything to ensure type safety when putting into the map.
  • amaidment
    amaidment almost 12 years
    But I don't have to do instanceof, because the object type of value I get out is determined by the generic of AbstractKey - that's the whole point of my design.
  • JB Nizet
    JB Nizet almost 12 years
    It improved type safety, because the only possible things that can be put into the map are Value instances. And those can only wrap a String or an Integer. In your question, you start from a Map<MyKey, Object>, which could contain any kind of Object. Maybe you should explain us, at a higher level, why you need to store Integer and String values into a unique map. If you had two maps (one for Integer values, and one for String values, you wouldn't have to create any specific class.
  • Alderath
    Alderath almost 12 years
    @amaidment TypedMap extends HashMap<AbstractKey, Object>. That class has a put method which accepts any type of AbstractKey and any type of Object as arguments. If you are doing put(AbstractKey<String>, String), the compiler will understand that the put implementation in TypedMap is applicable, and use that method. If you are doing put(AbstractKey<String>, Integer) the compiler will understand that TypedMap.put(...) is not applicable. Instead, the compiler will use Hashmap.put(...) which accepts any AbstractKey without caring about the parameterized type, and any Object.
  • amaidment
    amaidment almost 12 years
    Ok - when I said 'inherited', I misspoke. I meant it provides a method with a similar construction. However, I see that the reason map.put(StringKey.A, 10) doesn't show a compile error is that it calls the method in the HashMap superclass.
  • Marko Topolnik
    Marko Topolnik almost 12 years
    Exactly. That was my point with boolean return value (if you take into account that I thought Map.put did in fact return a boolean, just like Set.add.
  • amaidment
    amaidment almost 12 years
    The use of Strings and Integers is purely as an example. I actually want to use a multitude of different Keys - Value type pairs - will amend my question to make this clear.
  • JB Nizet
    JB Nizet almost 12 years
    Then use one map per key type/value type pair.
  • amaidment
    amaidment almost 12 years
    Thanks - +1 for your assistance.
  • amaidment
    amaidment almost 12 years
    Actually - I like your typedPut (and logically, typedGet) methods so much, I'll accept your answer
  • amaidment
    amaidment almost 12 years
    Thanks - that's a helpful reference.
  • maress
    maress almost 12 years
    Normally you can replace the AbstractKey<T> with Class<T> unless the abstract key contains more data than just a key?
  • amaidment
    amaidment almost 12 years
    This looks like just a derivation of Marko's answer, except that since TypedMap doesn't extend Map, it's possible to have put() and get() methods (rather than typedPut()). However, I want to extend Map - partly so that I get the functionality of a Map (iterators etc.), and partly so that it can be handled as a Map (e.g. in other custom renderers). I'm not sure what the added value is...?
  • trashgod
    trashgod almost 12 years
    You're welcome; see also Class Literals as Runtime-Type Tokens.
  • jontejj
    jontejj about 11 years
    Using one map is neat when passing it around, no need to pass several maps around.