Every so often I run into a problem that would just really neatly be solved by something I have come to call a TypeMap. The problem may not be so common that you would expect the JDK itself to provide a ready-made class for it. To me, hoewever, it seemed common enough that I was suprised to be unable to google my way towards the functionality I was after. Actually, I did stumble upon a class named exactly that: TypeMap. It was buried deep inside the JDK code base itself. And with a name like that, how could it not have the functionality I was after? But the class was in one of those not-for-you-earthling packages, so I didn't check.
So what does a TypeMap do? Obviously it is a type of Map. And also rather obviously, it is a Map whose keys are Class objects. But its get() and containsKey() methods behave in a way that clearly takes it beyond a regular map. They rely on, and leverage the structure of the Java type hierarchy to retrieve a value for the requested key. If the requested key (a Class object) is not itself present in the map, but one of its supertypes is, then the value associated with the supertype is returned (or true in the case of containsKey).
Associating Types with Functions
Type maps can be particularly useful as a function-lookup mechanism. You pass it the class of a value, and it comes back with the function that should be applied to that value. For example, suppose you are writing a print() method that needs to be a bit more sophisticated than calling toString() on the value to be printed. You might want to format all numbers using myNumberFormatter::format, all date/time objects using myDateTimeFormatter::format, and the rest with a simple Objects::toString. That would require just three map entries to cover every possible type: one for Number.class, one for TemporalAccessor.class and one for Object.class. Without needing to know anything about the value the print() method is about to print, it would pass the value's class to the type map's get() method and back comes the function that it should use to format the value.
Natural Default Values
Type maps also let you specify and hand out default values for groups of types in a very natural way — namely through a common ancestor. For example, say you want Integer.class to be associated with a special value, but all other Number types will have to make do with a shared default value. Using a TypeMap you would only have to add two keys to the map: Integer.class and Number.class.
In fact you could also associate the shared default value with Serializable.class or Object.class. A TypeMap will climb the requested type's type hierarchy until it finds a type that it implements or extends. Consequently, if Object.class is in the TypeMap, it is guaranteed to always return a non-null value for any type you throw at it. (The TypeMap interface that I ended up specifying myself does not allow null keys or null values.) The value associated with Object.class is the ultimate default value.
Implementations
The TypeMap
interface I specified is an extension of the
Map interface, but it does not add any new methods to it. It only
re-specifies the behaviour of get() and containsKey() as described above.
To use TypeMap and its implementations, add the following dependency to your POM file:
<dependency>
<groupId>org.klojang</groupId>
<artifactId>klojang-collections</artifactId
<version>2.0.8</version>
</dependency>
This will give you access to no less than four implementations. TypeMap is a sealed interface and the classes implementing it are hidden from view. You can only get hold of an implementation through static factory methods on the TypeMap interface itself. Three implementations are backed by a regular map:
- GreedyTypeMap is backed by a HashMap
- FixedTypeMap is backed by an unmodifiable map - the one you get from Map.of()
- TreeTypeMap is backed by a TreeMap
And then there is NativeTypeMap, which directly implements the Map interface and is not backed by a regular map.
While the first three implementations climb the requested type's type hierarchy until they reach Object.class (if present), the fourth implementation starts at the top of the hierarchy and works its way down until it finds the requested type. That may sound inefficient, but it is, in fact, the implementation you should choose unless you intend to create very large type maps. It consistently came out on top in JMH benchmarks comparing the four implementations.
TreeTypeMap is almost guaranteed to be the slowest of the four options. However, it has the nice feature that its keys are sorted roughly in ascending order of abstraction. More precisely: for any two keys, the one that comes first in the key set will never be a supertype of the one the comes second, and the one that comes second will never be a subtype of the one that comes first. The keys in a NativeTypeMap are sorted the other way round, from more abstract to less abstract. If Object.class is present, it will be the first element in the key set.
Type Sets
As you can imagine, TypeMap objects are themselves excellent backends for a particular type of set, naturally called a TypeSet within klojang-collections. This type of set can be very sparsely populated, yet still cover a wide range of types. For example, a TypeSet would only have to actually contain Enum.class, Number.class and Temporal.class to respond affirmatively when asked if it contains Integer.class, Doube.class, Long.class, LocalDate.class, LocalDateTime.class, Instant.class, DayOfWeek.class, FileVisitOption.class, etc. etc. etc.
Comments
Post a Comment