xZise
xZise

Reputation: 2379

Use generic enum classes in maps

I have a map of class names to their enum class and I have method that parses a string like "SomeEnum.FIRST" into the actual object. But Enum.valueOf doesn't accept Class<? extends Enum<?>> while the map cannot store Class<T extends Enum<T>>.

For the code, the map looks something like this:

    private static final HashMap<String, Class<? extends Enum<?>>> enumsMap;

    static {
        enumsMap = new HashMap<>();
        // These are two DIFFERENT enum classes!
        registerEnum(SomeEnum.class);
        registerEnum(AnotherEnum.class);
    }

    private static void registerEnum(Class<? extends Enum<?>> enumClass) {
        enumsMap.put(enumClass.getSimpleName(), enumClass);
    }

And here is the parser (removed unnecessary code):

    public <T extends Enum<T>> Object[] parse(List<String> strParameters) {
        Object[] parameters = new Object[strParameters.size()];
        for (int i = 0; i < parameters.length; i++) {
            String strParameter = strParameters.get(i);
            int delim = strParameter.lastIndexOf('.');
            String className = strParameter.substring(0, delim - 1);
            String enumName = strParameter.substring(delim + 1);
            Class<T> enumClass = (Class<T>) enumsMap.get(className);
            parameters[i] = Enum.valueOf(enumClass, enumName);
        }
        return parameters;
    }

And now if I call this parse, my IDE (Android Studio) tells me, that "Unchecked method 'parse(List)' invocation", and afaik this is because of that generic type. If I remove it in parse, it wouldn't compile but the warning disappears. Is there a good way around it?

Upvotes: 5

Views: 1340

Answers (2)

VGR
VGR

Reputation: 44414

There is no safe way to have Map values whose generic type depends on the corresponding key.

You can, however, store the enum constants yourself:

private static final Map<String, Map<String, ?>> enumsMap;

static {
    enumsMap = new HashMap<>();
    // These are two DIFFERENT enum classes!
    registerEnum(SomeEnum.class);
    registerEnum(AnotherEnum.class);
}

private static <T extends Enum<T>> void registerEnum(Class<T> enumClass) {
    Map<String, ?> valuesByName =
        EnumSet.allOf(enumClass).stream().collect(
            Collectors.toMap(Enum::name, Function.identity()));
    enumsMap.put(enumClass.getSimpleName(), valuesByName);
}

public Object[] parse(List<String> strParameters) {
    Object[] parameters = new Object[strParameters.size()];
    for (int i = 0; i < parameters.length; i++) {
        String strParameter = strParameters.get(i);
        int delim = strParameter.lastIndexOf('.');
        String className = strParameter.substring(0, delim);
        String enumName = strParameter.substring(delim + 1);
        Map<String, ?> enumValues = enumsMap.get(className);
        parameters[i] = enumValues.get(enumName);
        if (parameters[i] == null) {
            throw new IllegalArgumentException("Class " + className
                + " does not contain constant " + enumName);
        }
    }
    return parameters;
}

What I’ve changed:

  • enumsMap is now Map<String, Map<String, ?>>. Each value is a Map of enum constants keyed by constant name. ? is sufficient; there is no benefit to remembering that the constant values are enums, since parse returns Object[].
  • registerEnum has a generic type, to guarantee its argument is a valid enum type. Instead of storing the class argument, it stores that enum’s constants.
  • parse doesn’t need a generic type, since it returns Object[].
  • parse does not use any methods of Enum, so generic type safety is no longer a concern.
  • I fixed a bug: strParameter.substring(0, delim); instead of delim - 1. You want the entire substring up to but not including the period.

Upvotes: 1

Joshua Taylor
Joshua Taylor

Reputation: 85923

If you have enums like:

  enum Foo {
    A, B, C
  }

  enum Bar {
    D, E, F
  }

Then you can implement the kind of map you're talking about with the following code.

class MyEnums {
    private final Map<String, Class<? extends Enum<?>>> map = new HashMap<>();

    public void addEnum(Class<? extends Enum<?>> e) {
      map.put(e.getSimpleName(), e);
    }

    private <T extends Enum<T>> T parseUnsafely(String name) {
      final int split = name.lastIndexOf(".");
      final String enumName = name.substring(0, split);
      final String memberName = name.substring(split + 1);
      @SuppressWarnings("unchecked")
      Class<T> enumType = (Class<T>) map.get(enumName);
      return Enum.valueOf(enumType, memberName);
    }

    public Object parse(String name) {
      return parseUnsafely(name);
    }

    public Object[] parseAll(String... names) {
      return Stream.of(names)
          .map(this::parse)
          .collect(toList())
          .toArray();
    }
  }

This does not get around an unchecked cast, though; it only hides it from you temporarily. You can see where where SuppressWarnings is used to muffle the warning about enumType. It's generally good practice to apply the warning suppression in as limited a scope as possible. In this case, it's for that single assignment. While this could be a red flag in general, in the present case we know that the only values in the map are, in fact, enum classes, since they must have been added by addEnum.

Then, it can be used as:

  MyEnums me = new MyEnums();
  me.addEnum(Foo.class);
  me.addEnum(Bar.class);
  System.out.println(me.parse("Foo.A"));
  System.out.println(me.parse("Bar.E"));
  System.out.println(Arrays.toString(me.parseAll("Foo.B", "Bar.D", "Foo.C")));

which prints:

A
E
[B, D, C]

You'll notice that I broke parseUnsafely and parse into separate methods. The reason that we don't want to expose parseUnsafely directly is that it makes a guarantee by its return type that we cannot actually enforce. If it were exposed, then we could write code like

Bar bar = me.parseUnsafely("Foo.B");

which compiles, but fails at runtime with a cast class exception.

Upvotes: 5

Related Questions