What are the reasons why Map.get(Object key) is not (fully) generic
Asked Answered
G

11

454

What are the reasons behind the decision to not have a fully generic get method in the interface of java.util.Map<K, V>.

To clarify the question, the signature of the method is

V get(Object key)

instead of

V get(K key)

and I'm wondering why (same thing for remove, containsKey, containsValue).

Gurney answered 13/5, 2009 at 11:28 Comment(3)
Similar question regarding Collection: #105299Bengaline
Possible duplicate of Why aren't Java Collections remove methods generic?Francoisefrancolin
Amazing. I am using Java since 20+ years, and today I realize this problem.Lapstrake
C
288

As mentioned by others, the reason why get(), etc. is not generic because the key of the entry you are retrieving does not have to be the same type as the object that you pass in to get(); the specification of the method only requires that they be equal. This follows from how the equals() method takes in an Object as parameter, not just the same type as the object.

Although it may be commonly true that many classes have equals() defined so that its objects can only be equal to objects of its own class, there are many places in Java where this is not the case. For example, the specification for List.equals() says that two List objects are equal if they are both Lists and have the same contents, even if they are different implementations of List. So coming back to the example in this question, according to the specification of the method is possible to have a Map<ArrayList, Something> and for me to call get() with a LinkedList as argument, and it should retrieve the key which is a list with the same contents. This would not be possible if get() were generic and restricted its argument type.

Crazyweed answered 13/5, 2009 at 17:14 Comment(37)
Then why is V Get(K k) in C#?Therianthropic
The question is, if you want to call m.get(linkedList), why didn't you define m's type as Map<List,Something>? I can't think of a usecase where calling m.get(HappensToBeEqual) without changing the Map type to get an interface makes sense.Catha
Wow, serious design flaw. You get no compiler warning either, screwed up. I agree with Elazar. If this is really useful, which I doubt happens often, a getByEquals(Object key) sounds more reasonable...Maidamaidan
+1 to Elazar's comment. PLus, if you really want to be this flexible, use Object as the type for K.Proteus
This decision seems like it was made on the basis of theoretical purity rather than practicality. For the majority of usages, developers would much rather see the argument limited by the template type, than to have it unlimited to support edge cases like the one mentioned by newacct in his answer. Leaving the non-templated signatures creates more problems than it solves.Malik
Things get more confusing if working with EnumMap. A map that can only accept an Enum for its key type. One cannot override the equals(Object o) method for an enum, hence the argument that objects from different types can be equal to each other fails if one of these objects is an Enum. So, for an EnumMap, it doesn't make sence not to ask for the specific Enum type which was used to create the EnumMap. But it implements the Map interface so we're stuck with the get(Object o) method here, too.Coombs
One of the big reason for me to use Generics is type safety. And for me this is definitely a hole in that safety net.Diverge
@mishal153: It is perfectly type safe. Type safe means no unsafe casts that can fail. This does not add any casts.Crazyweed
@Sam Goldberg, it strikes me that the reason it ended up this way was to preserve backwards compatibility. That method was designed before Generics came out, and when the retro fit occurred Sun went out of their way to keep older code working the same way that it did before.Spinks
@newacct: “perfectly type safe” is a strong claim for a construct which can fail unpredictably at runtime. Don’t narrow your view to hash maps which happen to work with that. TreeMap may fail when you pass objects of the wrong type to the get method but may pass occasionally, e.g. when the map happens to be empty. And even worse, in case of a supplied Comparator the compare method (which has a generic signature!) might be called with arguments of the wrong type without any unchecked warning. This is broken behavior.Abramson
Note: HashMap.get() relies on equals as you say, but TreeMap.get() relies on a.compareTo(b) == 0, not equals/hashCode.Commemoration
@GlenPeterson: The contract of the Map interface is to use equals. The TreeMap documentation says that compareTo must be consistent with equals for it to properly implement Map. It is true that it will still work if compareTo is not consistent with equals, but then it isn't a proper implementation of Map.Crazyweed
@Crazyweed Everything you say is true. But... The whole point of providing a comparator to TreeMap is to define a context for comparison other than the one defined by equals(). Just sayin', when I realized that, it opened up a whole new world of goodness. Now I can define a comparator for some interface, then throw anything into a TreeSet (using that comparator) that implements that interface, even if the actual objects are totally incompatible with each other in terms of their one-sided equals() implementations.Commemoration
Using mutable lists as Map keys doesn't sound like a good idea (as both their equality to other objects as well as the value of their hash code is free to change, which may lead to unexpected consequences) and I don't think it should be provided here as an example as to why Map#get should be using equals.Chiang
@toniedzwiedz: You could have immutable lists then. The same issue would apply with different implementations of immutable lists. And this is not explaining why the Map API uses equals -- that has many reasons but is a separate question. This is explaining, given that the Map API uses equals, why Map.get takes Object.Crazyweed
@Crazyweed that's not a reason for this implementation, that's the problem with it. Losing compile time checks to cater for questionable design cases. A possible reason being... the Map#get method predates generics and it had to remain broken to maintain backward compatibility. Although at the same time, this wasn't a problem for Map#put which also used to use Object as the type of its parameters prior to Java 5. Your argument could also be applied to it, yet it IS generic now. How is this case different? The way I see it, you're reiterating what the contract is, not explaining why.Chiang
@toniedzwiedz: It has nothing to do with "backwards compatibility". In fact, you just proved that "backwards compatibility" has nothing to do with it, by noting that "backwards compatibility" does not prevent something from being generified when generics was added, e.g. for Map#put -- if the type E is indeed the proper intended type for that parameter. The fact that the parameter of Map#get is Object indicates that E is NOT the proper intended type for that parameter -- that if generics was in the first version of Java, the would still have used Object as the type.Crazyweed
@toniedzwiedz: "Your argument could also be applied to it" No it can't. In fact, that is the very reason why the types are different. The first argument of Map#put is the value that is put into the entry in the map, so obviously it must be the right type because it's the value that goes in the map. Map#get is to lookup a value that is already in the map (which has the right type) given an argument that is a different object, that just has to be equal. This passed object is never inserted into the map; it's basically just a selector used to determine which entry to pull out from the map.Crazyweed
Then what's wrong with a hypothetical generic Map#get(K key) and restricting the type to List in the example you provided? Why couldn't that work? You're describing how Map#get works and not why it works the way it works. Your example (List) is actually a very good use case for a generic Map#get, it just happens to use an overly restrictive key type.Chiang
@toniedzwiedz: Because the restriction is unnecessary (unlike Map#put where the argument is put in and needs to come out as E, Map#get doesn't actually use the argument in a way that requires it to be E) and incorrectly restrictive given its API (the API of Map#get only cares about the behavior of its .equals() method, which does not technically allow you to say anything about its type, given how .equals() is defined in Java).Crazyweed
@toniedzwiedz: From what you have said, it seems that the disagreement basically goes back to your belief that it's "questionable design" for instances of different classes to be equal, and therefore you think APIs should proactively constrain types so that when .equals() is used, it's enforced that both things should be the same type, even though it's not necessary on a language level. Well, I don't share that view, and that's why our conclusions are different.Crazyweed
@Crazyweed A Map<ArrayList, Something> object shall not contain a LinkedList key, because put will not allow any key parameter other than ArrayList.Willemstad
@LittleSanti: I agree, and I never claimed a Map<ArrayList, Something> object contains any LinkedList keys, so I am not sure what you are referring to.Crazyweed
@Crazyweed You wrote "is possible to have a Map<ArrayList, Something> and for me to call get() with a LinkedList as argument".Willemstad
@LittleSanti: And that's absolutely correct. You should be able to call get() with a LinkedList as argument, and that has nothing to do with a LinkedList key being in the Map. Read the contract of get() -- it gets a value in the Map whose key is equal to the passed object, not a key that is the same object. So you can get things out of a Map with get() by passing an argument which is not in the Map.Crazyweed
@Crazyweed Not only an argument which is not in the map itself, but that is not even the type parameter defined for that map. Sounds quite, quite weird.Willemstad
@LittleSanti: Yes, since objects of different types can be equal.Crazyweed
If calling get with a LinkedList when the key type is ArrayList, is an example that must work due to the equality constraint, then put with the same LinkedList should also work when the equal key is already present. Then, in turn computeIfPresent should accept Object as key as well, as it never stores the specified key, but only modifies the map when the equal key is already present. Same for replace, which never stores the specified key.Abramson
@Holger: But we don't know at compile-time if an equal key is already present, so they cannot allow you to call put with a value of compile-time type LinkedList. The default implementation for computeIfPresent does store the specified key into the map if the functions returns non-null, and the default implementation of replace does store the specified key into the map if the old value matches the passed value. (the Map interface specifies no method to efficiently get the key in the map that is equal to a given key, nor a method to set a value without also setting the key)Crazyweed
The default implementations of computeIfPresent and replace rely on put, but that clearly specifies “If the map previously contained a mapping for the key, the old value is replaced by the specified value.” So no key replacement for these methods. The JRE has several Set implementations relying on this contract. Regarding put itself, you are right, the compiler can’t check whether the key is already present, but you derived a conclusion for get from the equality definition that does apply to put the same way. So if your conclusion is right, the put method is broken.Abramson
@Holger: The specification you quote of Map.put says that if the map contains a mapping for the key, the value is replaced, but it is silent on whether the key is replaced, or whether the old key is retained, so some class that implements Map might replace the key, and it would be unsafe for a default implementation of computeIfPresent or replace to assume that the implementation doesn't store the key.Crazyweed
@Holger: And even if the implementing class doesn't store the key in the case where the mapping already exists, since Map.put() specifies that it takes type K, the implementation of put in an implementing class should be able to rely on the fact that the passed argument is type K, and be able do things to it that can only be done with type K (other than storing it int the map), so it would be unsafe for the default implementation of computeIfPresent or replace to cast something that might not be K into K when it calls put.Crazyweed
Collections.newSetFromMap relies on Map.put not changing an existing key, as otherwise, it would violate the contract of Set.add. The rest of your comment is circular reasoning. So your explanation why get must take Object does not apply to other methods when they do not accept Object but require K. So, in other words, your explanation would be moot if get required K too. The safety argument is pointless, as get isn’t safe either. E.g. TreeMap.get does throw a ClassCastException when the specified object isn’t Comparable resp. incompatible to the Comparator.Abramson
@Holger: get and put are methods specified by the Map interface, while computeIfPresent and replace are default implementations added later. If computeIfPresent or replace were methods originally specified by the Map interface that implementing classes had to implement, then the signature could take Object and the implementing class would have to implement the method to not rely on the argument being K.Crazyweed
@Holger: But, computeIfPresent and replace are default implementations that have to work with classes that did not implement them without knowing how the specific implementation works, so they must use put to set the value, and since put takes K (for reasons already explained), and the implementing class is allowed to rely on the argument being K, the default implementations of computeIfPresent and replace must take K also, to pass into put.Crazyweed
Actually, the signatures of the replace methods were already fixed in Java 5, when default methods did not even exist, as the ConcurrentMap interface defines them since then and Map could not introduce methods with a different signature without breaking compatibility. The methods of ConcurrentMap are required to work atomically, which rules out implementing them atop put. The reasoning is incomplete at best, but tends to be just inconsistent. But anyway, that’s already a too long discussion and it doesn’t seem like we’ll find an agreement…Abramson
@Holger: I agree that in the case of ConcurrentMap.replace(), it should be able to take Object as the type of the key parameter, since, unlike with Map, replace was a method that ConcurrentMap implementations had to implement from the beginning. I don't know why they didn't use Object.Crazyweed
J
120

An awesome Java coder at Google, Kevin Bourrillion, wrote about exactly this issue in a blog post a while ago (admittedly in the context of Set instead of Map). The most relevant sentence:

Uniformly, methods of the Java Collections Framework (and the Google Collections Library too) never restrict the types of their parameters except when it's necessary to prevent the collection from getting broken.

I'm not entirely sure I agree with it as a principle - .NET seems to be fine requiring the right key type, for example - but it's worth following the reasoning in the blog post. (Having mentioned .NET, it's worth explaining that part of the reason why it's not a problem in .NET is that there's the bigger problem in .NET of more limited variance...)

Jumna answered 13/5, 2009 at 11:42 Comment(10)
I'm sure Josh Bloch has written about it somewhere. An earlier attempt did use the generic parameter for the parameter, but was found to be too awkward.Hectorhecuba
The problem that Kevin cites with Set<? extends Foo> is an artificial one, even in Java. If you pass a type parameter <F extends Foo> and then use Set<F>, the problem goes away. So I very much doubt that this is the reason why get, contains, and containsKey take Object.Oxford
Apocalisp: that's not true, the situation is still the same.Tarragona
It's wrong. The post complains that Set<? extends Foo> would be unable to take any non-null value in its contains() method. But if objects of different types could not be equal (suppose that .equals() throws an exception on an object of a different class), then that restriction would be the right behavior from a type safety point of view. Why would you want to be able to test a set of unknown component type for the membership of an object that may not be the right type? That would be type unsafe. Only when you consider that objects of different types can be equal does it makes sense.Delative
@Delative No, the post is not wrong. Even though an Integer and a Double can never be equal to one another, it's still a fair question to ask whether a Set<? extends Number> contains the value new Integer(5).Tarragona
I have never once wanted to check membership in a Set<? extends Foo>. I have very frequently changed the key type of a map and then been frustrated that the compiler could not find all the places where the code needed updating. I am really not convinced that this is the correct tradeoff.Bouffe
The first example of that blog is now broken because new Long(10).equals(new Integer(10)) is false in Java 7.Deerhound
@EarthEngine: It's always been broken. That's the whole point - the code is broken, but the compiler can't catch it.Jumna
And its still broken, and just caused us a bug ... awesome answer.Lapstrake
@TomHawtin-tackline Maybe the reference in this answer? https://mcmap.net/q/81513/-why-aren-39-t-java-collections-remove-methods-generic-duplicateHysterectomy
S
31

The contract is expressed thus:

More formally, if this map contains a mapping from a key k to a value v such that (key==null ? k==null : key.equals(k)), then this method returns v; otherwise it returns null. (There can be at most one such mapping.)

(my emphasis)

and as such, a successful key lookup depends on the input key's implementation of the equality method. That is not necessarily dependent on the class of k.

Sedgewinn answered 13/5, 2009 at 11:34 Comment(5)
It is also dependent on hashCode(). Without a proper implementation of hashCode(), a nicely implemented equals() is rather useless in this case.Regret
I guess, in principle, this would let you use a lightweight proxy for a key, if recreating the whole key was impractical - as long as equals() and hashCode() are correctly implemented.Biography
@rudolfson: As far as I'm aware, only a HashMap is reliant upon the hash code to find the correct bucket. A TreeMap, for example, uses a binary search tree, and doesn't care about hashCode().Penoyer
Strictly speaking, get() does not need to take an argument of type Object to satisfy the contact. Imagine the get method were restricted to the key type K - the contract would still be valid. Of course, uses where the compile time type was not a subclass of K would now fail to compile, but that doesn't invalidate the contract, since contracts implicitly discuss what happens if the code compiles.Uzzi
When we talk about the contract, especially around key.equals(k), I think it is important to also note that the contract also allows simply throwing a ClassCastException is key is of an incompatible type. This reduces the usefulness of remove(Object) as a sort of removeIf(Predicate), because it depends on the collection implementation. If we are depending on a particular collection implementation's behavior, then maybe that should be exposed as a method of that particular implementation and the collection interface should have consistent behavior for all implementations.Nial
A
15

It's an application of Postel's Law, "be conservative in what you do, be liberal in what you accept from others."

Equality checks can be performed regardless of type; the equals method is defined on the Object class and accepts any Object as a parameter. So, it makes sense for key equivalence, and operations based on key equivalence, to accept any Object type.

When a map returns key values, it conserves as much type information as it can, by using the type parameter.

Addy answered 25/6, 2009 at 19:24 Comment(6)
Then why is V Get(K k) in C#?Therianthropic
It's V Get(K k) in C# because it also makes sense. The difference between the Java and .NET approaches is really only who blocks off non-matching stuff. In C# it's the compiler, in Java it's the collection. I rage about .NET's inconsistent collection classes once in a while, but Get() and Remove() only accepting a matching type certainly prevents you from accidentally passing a wrong value in.Simulacrum
It's a mis-application of Postel's Law. Be liberal in what you accept from others, but not too liberal. This idiotic API means that you can't tell the difference between "not in the collection" and "you made a static typing mistake". Many thousands of lost programmer hours could have been prevented with get : K -> boolean.Hoplite
Of course that should have been contains : K -> boolean.Hoplite
Postel was wrong.Rebuttal
@Rebuttal Perhaps. My answer isn't to justify or defend the choice that was made, but simply to answer the question, why was this approach chosen.Addy
M
14

I think this section of Generics Tutorial explains the situation (my emphasis):

"You need to make certain that the generic API is not unduly restrictive; it must continue to support the original contract of the API. Consider again some examples from java.util.Collection. The pre-generic API looks like:

interface Collection { 
  public boolean containsAll(Collection c);
  ...
}

A naive attempt to generify it is:

interface Collection<E> { 
  public boolean containsAll(Collection<E> c);
  ...
}

While this is certainly type safe, it doesn’t live up to the API’s original contract. The containsAll() method works with any kind of incoming collection. It will only succeed if the incoming collection really contains only instances of E, but:

  • The static type of the incoming collection might differ, perhaps because the caller doesn’t know the precise type of the collection being passed in, or perhaps because it is a Collection<S>,where S is a subtype of E.
  • It’s perfectly legitimate to call containsAll() with a collection of a different type. The routine should work, returning false."
Manslayer answered 14/5, 2009 at 14:16 Comment(3)
why not containsAll( Collection< ? extends E > c ), then?Hoplite
@JudgeMental, though not given as an example above it is also necessary to allow containsAll with a Collection<S> where S is a supertype of E. This would not be allowed if it were containsAll( Collection< ? extends E > c ). Furthermore, as is explicitly stated in the example, it's legitimate to pass a collection of a different type (with the return value then being false).Dissatisfy
It should not be necessary to allow containsAll with a collection of a supertype of E. I argue that it is necessary to disallow that call with a static type check to prevent a bug. It's a silly contract, which I think is the point of the original question.Hoplite
Y
9

Compatibility.

Before generics were available, there was just get(Object o).

Had they changed this method to get(<K> o) it would have potentially forced massive code maintenance onto java users just to make working code compile again.

They could have introduced an additional method, say get_checked(<K> o) and deprecate the old get() method so there was a gentler transition path. But for some reason, this was not done. (The situation we are in now is that you need to install tools like findBugs to check for type compatibility between the get() argument and the declared key type <K> of the map.)

The arguments relating to the semantics of .equals() are bogus, I think. (Technically they're correct, but I still think they're bogus. No designer in his right mind is ever going to make o1.equals(o2) true if o1 and o2 do not have any common superclass.)

Yoicks answered 23/3, 2017 at 8:38 Comment(2)
But there also the method put(Object key, Object value) has been changed to put(K key, V value), and no problem with that!Dogvane
That's presumably because if it's "working code", then the objects being passed to any put method invocation for key and value would already "naturally" satisfy the stricter constraints. Thus there might have been an expectation that "working code" would simply recompile without review in +- 99% of the cases. If not more.Yoicks
O
6

The reason is that containment is determined by equals and hashCode which are methods on Object and both take an Object parameter. This was an early design flaw in Java's standard libraries. Coupled with limitations in Java's type system, it forces anything that relies on equals and hashCode to take Object.

The only way to have type-safe hash tables and equality in Java is to eschew Object.equals and Object.hashCode and use a generic substitute. Functional Java comes with type classes for just this purpose: Hash<A> and Equal<A>. A wrapper for HashMap<K, V> is provided that takes Hash<K> and Equal<K> in its constructor. This class's get and contains methods therefore take a generic argument of type K.

Example:

HashMap<String, Integer> h =
  new HashMap<String, Integer>(Equal.stringEqual, Hash.stringHash);

h.add("one", 1);

h.get("one"); // All good

h.get(Integer.valueOf(1)); // Compiler error
Oxford answered 13/5, 2009 at 13:38 Comment(2)
This in itself does not prevent the type of 'get' from being declared as "V get(K key)", because 'Object' is always an ancestor of K, so "key.hashCode()" would still be valid.Tiltyard
While it doesn't prevent it, I think it explains it. If they switched the equals method to force class equality, they certainly couldn't tell people that the underlying mechanism for locating the object in the map utilizes equals() and hashmap() when the method prototypes for those methods aren't compatible.Edithe
M
5

There is one more weighty reason, it can not be done technically, because it brokes Map.

Java has polymorphic generic construction like <? extends SomeClass>. Marked such reference can point to type signed with <AnySubclassOfSomeClass>. But polymorphic generic makes that reference readonly. The compiler allows you to use generic types only as returning type of method (like simple getters), but blocks using of methods where generic type is argument (like ordinary setters). It means if you write Map<? extends KeyType, ValueType>, the compiler does not allow you to call method get(<? extends KeyType>), and the map will be useless. The only solution is to make this method not generic: get(Object).

Margret answered 1/1, 2014 at 14:37 Comment(2)
why is the set method strongly typed then?Kiesha
if you mean 'put': The put() method changes map and it will not be avaliable with generics like <? extends SomeClass>. If you call it you got compile exception. Such map will be "readonly"Margret
A
2

Backwards compatibility, I guess. Map (or HashMap) still needs to support get(Object).

Anthropophagi answered 13/5, 2009 at 11:33 Comment(2)
But the same argument could be made for put (which does restrict the generic types). You get backwards compatibility by using raw types. Generics are "opt-in".Cannibal
Personally, I think the most likely reason for this design decision is backwards compatibility.Mccloskey
P
2

I was looking at this and thinking why they did it this way. I don't think any of the existing answers explains why they couldn't just make the new generic interface accept only the proper type for the key. The actual reason is that even though they introduced generics they did NOT create a new interface. The Map interface is the same old non-generic Map it just serves as both generic and non-generic version. This way if you have a method that accepts non-generic Map you can pass it a Map<String, Customer> and it would still work. At the same time the contract for get accepts Object so the new interface should support this contract too.

In my opinion they should have added a new interface and implemented both on existing collection but they decided in favor of compatible interfaces even if it means worse design for the get method. Note that the collections themselves would be compatible with existing methods only the interfaces wouldn't.

Piezoelectricity answered 18/12, 2014 at 13:0 Comment(0)
A
2

We are doing big refactoring just now and we were missing this strongly typed get() to check that we did not missed some get() with old type.

But I found workaround/ugly trick for compilation time check: create Map interface with strongly typed get, containsKey, remove... and put it to java.util package of your project.

You will get compilation errors just for calling get(), ... with wrong types, everything others seems ok for compiler (at least inside eclipse kepler).

Do not forget to delete this interface after check of your build as this is not what you want in runtime.

Abby answered 9/2, 2017 at 14:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.