0

I have a method that should only accept a Map whose key is of type String and value of type Integer or String, but not, say, Boolean.

For example,

map.put("prop1", 1); // allowed
map.put("prop2", "value"); // allowed
map.put("prop3", true); // compile time error

It is not possible to declare a Map as below (to enforce compile time check).

void setProperties(Map<String, ? extends Integer || String> properties)

What is the best alternative other than declaring the value type as an unbounded wildcard and validating for Integer or String at runtime?

void setProperties(Map<String, ?> properties)

This method accepts a set of properties to configure an underlying service entity. The entity supports property values of type String and Integer alone. For example, a property maxLength=2 is valid, defaultTimezone=UTC is also valid, but allowDuplicate=false is invalid.

Somu
  • 3,593
  • 6
  • 34
  • 44
  • Some additional reading https://stackoverflow.com/questions/745756/java-generics-wildcarding-with-multiple-classes?rq=1 (but doesn't work for "internals") –  Jul 04 '17 at 11:00
  • What is your method going to _do_ with this map? I can't help thinking this is an XY problem.. – Dawood ibn Kareem Jul 04 '17 at 11:10
  • Thanks @DawoodibnKareem. I edited my question to add a purpose to avoid XY problem. Hope this helps. – Somu Jul 04 '17 at 11:20
  • OK, so what's the type of the values in the "underlying service entity"? – Dawood ibn Kareem Jul 04 '17 at 11:31
  • Type of values are Integer and String. Valid values are 2, "UTC". Invalid values are 9.99, false. – Somu Jul 04 '17 at 11:35
  • You can convert everything to a `String`, so what’s the point of rejecting non-string values here? Is `"true"` less wrong than `true` when appearing in the map values? I guess, the underlying service doesn’t even recognize the difference. Or why is `Integer` a legit value but `Byte` isn’t? – Holger Jul 04 '17 at 16:59
  • @Holger, the service indeed differentiates "1" vs 1, and "true" vs true and fails on "1" and "true". – Somu Jul 05 '17 at 05:52

5 Answers5

5

Another solution would be a custom Map implementation and overrides of the put and putAll methods to validate the data:

public class ValidatedMap extends HashMap<String, Object> {
    @Override
    public Object put(final String key, final Object value) {
        validate(value);
        return super.put(key, value);
    }

    @Override
    public void putAll(final Map<? extends String, ?> m) {
        m.values().forEach(v -> validate(v));
        super.putAll(m);
    }

    private void validate(final Object value) {
        if (value instanceof String || value instanceof Integer) {
            // OK
        } else {
            // TODO: use some custom exception
            throw new RuntimeException("Illegal value type");
        } 
    }
}

NB: use the Map implementation that fits your needs as base class

  • I think this is the best answer here. It allows for a lot more flexibility and code reuse. Very nice. – SaxyPandaBear Jul 04 '17 at 11:08
  • 2
    And it could simply be enhanced: there is **no** need to hardcode the allowed types like shown. You could simply pass a `List` of allowed types; and then use class.isAssignableFrom() to check incoming values. Of course one has to keep in mind that this validation takes place for each and any add. – GhostCat Jul 04 '17 at 13:09
3

Since Integer and String closest common ancestor in the class hierarchy is Object you cannot achieve what you are trying to do - you can help compiler to narrow the type to Object only.

You can either

  • wrap your value into a class which can contain either Integer or String, or
  • extend Map as in the @RC's answer, or

  • wrap 2 Maps in a class

diginoise
  • 7,352
  • 2
  • 31
  • 39
  • @DawoodibnKareem I think the OP want to mix. –  Jul 04 '17 at 11:08
  • I want a Map that contains mixture of `String` and `Integer`. Sorry for the ambiguity. I assumed it was clear in my question as I put the examples and a "desired" method signature. – Somu Jul 04 '17 at 11:13
  • 1
    @Somu I have edited the answer. You cannot achieve what you want. – diginoise Jul 04 '17 at 11:23
  • @diginoise, could you please give an example of the 1st option, i.e. *wrap your value into a class which can contain either Integer or String*? – Somu Jul 04 '17 at 11:38
  • @Diginoise actually the closest common ancestor is `Serializable`. As in, if you call `Arrays.asList("", 0)` its type is `List`. – Andy Turner Jul 04 '17 at 18:49
  • I think I get now what the first solution (*wrapper class*) is suggesting - it's being used in @holger's solution – Somu Jul 05 '17 at 11:33
2

Define two overloads:

void setIntegerProperties(Map<String, Integer> properties)

void setStringProperties(Map<String, String> properties)

They have to be called different things, because you can't have two methods with the same erasure.

Andy Turner
  • 137,514
  • 11
  • 162
  • 243
2

You can’t declare a type variable to be either of two types. But you can create a helper class to encapsulate values not having a public constructor but factory methods for dedicated types:

public static final class Value {
    private final Object value;
    private Value(Object o) { value=o; }
}
public static Value value(int i) {
    // you could verify the range here
    return new Value(i);
}
public static Value value(String s) {
    // could reject null or invalid string contents here
    return new Value(s);
}
// these helper methods may be superseded by Java 9’s Map.of(...) methods
public static <K,V> Map<K,V> map(K k, V v) { return Collections.singletonMap(k, v); }
public static <K,V> Map<K,V> map(K k1, V v1, K k2, V v2) {
    final HashMap<K, V> m = new HashMap<>();
    m.put(k1, v1);
    m.put(k2, v2);
    return m;
}
public static <K,V> Map<K,V> map(K k1, V v1, K k2, V v2, K k3, V v3) {
    final Map<K, V> m = map(k1, v1, k2, v2);
    m.put(k3, v3);
    return m;
}
public void setProperties(Map<String, Value> properties) {
    Map<String,Object> actual;
    if(properties.isEmpty()) actual = Collections.emptyMap();
    else {
        actual = new HashMap<>(properties.size());
        for(Map.Entry<String, Value> e: properties.entrySet())
            actual.put(e.getKey(), e.getValue().value);
    }
    // proceed with actual map

}

If you are using 3rd party libraries with map builders, you don’t need the map methods, they’re convenient for short maps only. With this pattern, you may call the method like

setProperties(map("mapLength", value(2), "timezone", value("UTC")));

Since there are only the two Value factory methods for int and String, no other type can be passed to the map. Note that this also allows using int as parameter type, so widening of byte, short etc. to int is possible here.

Holger
  • 285,553
  • 42
  • 434
  • 765
  • Thanks for a compile-time solution. Are you missing a public getValue() in Value class? – Somu Jul 05 '17 at 07:51
  • 1
    This is designed as nested class within the class having the `setProperties` method; that’s why it can access the `private` field `value` directly, which it does when extracting the `Map` to a `Map`. You can add a getter method for the value, if it fits your needs, but for the primary use case, it’s not necessary. – Holger Jul 05 '17 at 07:59
  • I was looking for a solution with compile-time check. Hence this is my accepted answer. Other answers are equally awesome! – Somu Jul 05 '17 at 11:36
1

I'm fairly certain if any language was going to disallow multiple accepted types for a value, it would be Java. If you really need this kind of capability, I'd suggest looking into other languages. Python can definitely do it.

What's the use case for having both Integers and Strings as the values to your map? If we are really dealing with just Integers and Strings, you're going to have to either:

  1. Define a wrapper object that can hold either a String or an Integer. I would advise against this though, because it will look a lot like the other solution below.
  2. Pick either String or Integer to be the value (String seems like the easier choice), and then just do extra work outside of the map to work with both data types.
Map<String, String> map;
Integer myValue = 5;
if (myValue instanceof Integer) {
    String temp = myValue.toString();
    map.put(key, temp);
}

// taking things out of the map requires more delicate care.
try { // parseInt() can throw a NumberFormatException
    Integer result = Integer.parseInt(map.get(key)); 
}
catch (NumberFormatException e) {} // do something here

This is a very ugly solution, but it's probably one of the only reasonable solutions that can be provided using Java to maintain some sense of strong typing to your values.

SaxyPandaBear
  • 377
  • 1
  • 6