7

My requirement: I have an interface that shall only contain entries such as public final static short SOME_CONST = whatever. The catch: the short constants need to be unique. And in when there are duplicates, I am mainly interested in having the SOME_CONST_A, SOME_CONST_B, ... names causing the conflict.

I wrote the below test to test that via reflection. It works, but I find it clunky and not very elegant:

@Test
public void testIdsAreUnique() {
    Map<Short, List<String>> fieldNamesById = new LinkedHashMap<>();
    Arrays.stream(InterfaceWithIds.class.getDeclaredFields())
            .filter(f -> f.getClass().equals(Short.class))
            .forEach((f) -> {
        Short key = null;
        String name = null;
        try {
            key = f.getShort(null);
            name = f.getName();
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        fieldNamesById.computeIfAbsent(key, x -> new ArrayList<>()).add(name);
    });

    assertThat(fieldNamesById.entrySet().stream().filter(e -> e.getValue().size() > 1)
            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), is(Collections.emptyMap()));
}

Is there a way to avoid that intermediate local map instance?

( bonus question: is there a nicer way to shorten the lambda that fills the map with key/value pairs? )

GhostCat
  • 137,827
  • 25
  • 176
  • 248

5 Answers5

4

Here's a stream that's grouping fields by the static value. Note some comments about other changes/corrections

Map<Short, List<String>> fieldNamesById = 
        Arrays.stream(InterfaceWithIds.class.getDeclaredFields())

         //using short.class, not Short.class
        .filter(f -> f.getType().equals(short.class)) 

        //group by value, mapping fields to their names in a list
        .collect(Collectors.groupingBy(f -> getValue(f),
                Collectors.mapping(Field::getName, Collectors.toList())));

The method called to read the value is below (primarily meant to avoid try/catch blocks in the stream):

private static Short getValue(Field f) {
    try {
        return f.getShort(null);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
ernest_k
  • 44,416
  • 5
  • 53
  • 99
  • 2
    I had a check for the modifiers as well, until I realized: it is an interface, so all members are implicitly public final static. So that check is redundant ;-) – GhostCat Sep 19 '18 at 13:23
  • @ernest_k and now you can reduce the entire map ( where list.size() > 1 ) to a single string, like and answer below and simply assert that against an empty string – Eugene Sep 19 '18 at 13:42
  • 2
    There’s no need to use `equals` for classes, `f.getType() == short.class` is sufficient. @Eugene I’d rather use `Collectors.groupingBy(…, HashMap::new, …)` to ensure a mutable map, followed by `fieldNamesById.values().removeIf(l -> l.size()==1);` and finally `if(!fieldNamesById.isEmpty()) throw new AssertionError(fieldNamesById.toString());` – Holger Sep 19 '18 at 15:46
  • @Holger nice, agreed, much cleaner. – Eugene Sep 19 '18 at 18:58
4

If you want this check efficiently (normally not so much a concern for unit test), you can reduce the work by optimistically assuming that the fields have no duplicates and performing a cheap pre-test first. Further, you can use the result of this pre-test for getting the actual field with duplicates (if there are some) without a Map.

As pre-requisite, we should encapsulate the reflective operation

private static int fieldValue(Field f) {
    try {
        return f.getShort(null);
    }
    catch(ReflectiveOperationException ex) {
        throw new IllegalStateException();
    }
}

Further, we need to map the potential values of the short value range to a positive index for a BitSet:

private static int shortToIndex(int shortValue) {
    return Math.abs(shortValue<<1) | (shortValue>>>31);
}

This assumes that numbers with smaller magnitude are more common and keeps their magnitude small, to reduce the size of the resulting BitSet. If the values are assumed to be positive, shortValue & 0xffff would be preferable. If neither applies, you could also use shortValue - Short.MIN_VALUE instead.

Having the mapping function, we can use

@Test
public void testIdsAreUnique() {
    BitSet value = new BitSet(), duplicate = new BitSet();

    Field[] fields = InterfaceWithIds.class.getDeclaredFields();
    Arrays.stream(fields)
        .filter(f -> f.getType() == short.class)
        .mapToInt(f -> shortToIndex(fieldValue(f)))
        .forEach(ix -> (value.get(ix)? duplicate: value).set(ix));

    if(duplicate.isEmpty()) return; // no duplicates

    throw new AssertionError(Arrays.stream(fields)
        .filter(f -> duplicate.get(shortToIndex(fieldValue(f))))
        .map(f -> f.getName()+"="+fieldValue(f))
        .collect(Collectors.joining(", ", "fields with duplicate values: ", "")));
}

It first fills a bitset for all encountered values and another bitset for those encountered more than once. If the latter bitset is empty, we can return immediately as there are no duplicates. Otherwise, we can use that bitset as a cheap filter to get the field having the problematic values.

Holger
  • 285,553
  • 42
  • 434
  • 765
2

With your actual solution you are not very far.
You could relying on groupingBy() and mapping() in a first map collect in order to collect field names by field value. In this way you don't need any intermediary Map.

Map<Short, List<String>> map = 
Arrays.stream(InterfaceWithIds.class.getDeclaredFields())
     .filter(f -> f.getType()
                   .getClass()
                   .equals(short.class))
     .map(f -> {
         Short key = null;
         String name = null;
         try {
             key = f.getShort(null);
             name = f.getName();
         } catch (IllegalAccessException e) {
             throw new RuntimeException(e);
         }
         return new AbstractMap.SimpleEntry<>(key, name);
     })
     .collect(groupingBy(SimpleEntry::getKey, LinkedHashMap::new, mapping(e -> e.getValue(), Collectors.toList())))
     .entrySet()
     .stream()
     .filter(e -> e.getValue()
                   .size() > 1)
     .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

assertThat(map, is(Collections.emptyMap()));
davidxxx
  • 125,838
  • 23
  • 214
  • 215
  • After a bit of forth and back, I got your solution to work. But now things are so complicated that I feel forced to put a comment above the whole thing. Which basically means: maybe I should stick with my first solution, because that was comprehensible without any additional comment ;-O – GhostCat Sep 19 '18 at 14:08
  • I completely agree that your initial solution is better. – davidxxx Sep 19 '18 at 14:39
  • 1
    Honestly I think that you should accept and follow the ernest_k solution. It avoids the `SimpleEntry` use. – davidxxx Sep 19 '18 at 14:43
2

Not sure this would fit your needs, but why not simply:

 ...filter(..)
    .collect(Collectors.toMap(f -> f.getShort(null), Field::getName))

If there are duplicates, this will fail with an Exception. Catch that and do Assert.fail(...) for example.

I hope I got the code right, typing this on the phone

Eugene
  • 117,005
  • 15
  • 201
  • 306
  • Unless I am mistaken, this stops on the first error. I want to collect all issues, and print them once. – GhostCat Sep 19 '18 at 15:11
  • 1
    @GhostCat I understood that after posting, and there are already good answers here, so I did not edit – Eugene Sep 19 '18 at 15:48
1

A few problems here. Firstly f.getClass() will give you the class of the Field instance, and not the actual class of the field. You want

f.getType().equals(Short.class)

Next, you need to remember that Short.class and short.class are different, so you actually want

f.getType().equals(Short.class) || f.getType().equals(short.class)

I would personally take advantage of that fact that map.put returns the previous value for the given key. Because we hope that there will never have been a previous value, we can simply call assertNull on the result.

Your entire test would look like this:

Map<Short, String> fieldNamesById = new LinkedHashMap<>();
Arrays.stream(InterfaceWithIds.class.getDeclaredFields())
    .filter(f -> f.getType().equals(Short.class) || f.getType().equals(short.class))
    .forEach((f) -> {
        Short key = null;
        String name = null;
        try {
            key = f.getShort(null);
            name = f.getName();
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }

        assertNull(fieldNamesById.put(key, name));
    });

If you want to report all errors, try this:

List<String> problems = new ArrayList<>();

Map<Short, String> fieldNamesById = new LinkedHashMap<>();
Arrays.stream(InterfaceWithIds.class.getDeclaredFields())
    .filter(f -> f.getType().equals(Short.class) || f.getType().equals(short.class))
    .forEach((f) -> {
        Short key = null;
        String name = null;
        try {
            key = f.getShort(null);
            name = f.getName();
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }

        String prevValue = fieldNamesById.put(key, name);
        if (prevValue != null) problems.add("key " + key + " mapped to " + name + " and " + prevValue);
    });

assertTrue(problems.toString(), problems.isEmpty());
Michael
  • 41,989
  • 11
  • 82
  • 128
  • I didn't want to fail within a loop, I want the testcase to print **all** issues to me, instead of failing on the first one. – GhostCat Sep 19 '18 at 13:18
  • @GhostCat oh so you want to report all the problems at once? – Eugene Sep 19 '18 at 13:32
  • @Michael that error message is going to look very funny for 3 of the same values... – Eugene Sep 19 '18 at 13:33
  • @Eugene I considered that and did a test for it locally. Sample output: *key 1 mapped to OTHER and SOME_CONST, key 1 mapped to TEST and OTHER*. I don't think that's particularly bad, personally. Maybe not the optimal error message, but he asked how he could make the code of the test more readable, and to some extent there must be a trade-off. – Michael Sep 19 '18 at 13:36
  • @Michael it looks ugly, IMO. You can take ernest answer that does the computing of the map, stream that, filter where the size > 1, and print all messages, at once; thus no trade offs – Eugene Sep 19 '18 at 13:40
  • @Eugene The trade-off becomes the complexity of the assertion. My assertion is a single obvious line. GhostCat's assertion, and any assertion based on iterating the map's keys, is necessarily more complex. – Michael Sep 19 '18 at 13:51
  • @Michael right. I guess its the OP to decide, *I* would go for something else – Eugene Sep 19 '18 at 13:52
  • @Eugene And me. I'd use the `assertNull` version. Running a test multiple times to eliminate all of the failures is fairly common. It's only in very specific cases like these where you can *know* that all possible failures can be detected in a single run of the test. – Michael Sep 19 '18 at 14:05