Java中map.copyOf未使用源map比较器

3

我有大量使用自定义密钥和比较器的地图。我注意到当我像下面这样使用代码创建地图时,

    var map = TreeMap<>( someCustomComparator );

接着,我使用以下代码创建一个不可变的(小而快速的)副本:

    map = Map.copyOf( map );

然后,map.get(similarObject)就无法检索到someObject,即使在使用比较器someCustomComparator比较下,someObjectsimilarObject相等(“具有相同的等价类”)。

通过对API进行调试,我发现Map.copyOf返回一个使用Object::equals来比较键的映射实现,即它没有使用用于构造原始映射的比较器(在我的示例中,这将是someCustomComparator)。显然,当someObjectsimilarObject不是同一个对象但在someCustomComparator下具有相同的等价类而Object::equals没有被覆盖时,这将产生奇怪的结果。

    map.get( similarObject ) ==> someObject

在执行map = Map.copyOf(map)指令之前,以及

    map.get( similarObject ) ==> null 

map = Map.copyOf( map )指令执行后,这种行为是否可以预期我必须接受或者应该报告Java缺陷?

(请注意,some/similarObject的类也实现了comparable,但这也被Map.copyOf实现所忽略。)

(我认为这种行为在所有的集合copyOf实现中都是普遍存在的。)


1
你好,欢迎!new TreeMap<K, V>(SortedMap<K, ? extends V> oldMap); 是更加安全的(类型和排序)! - xerx593
嗨。我不确定我理解“类型更安全”,因为TreeMap实现了Navigable map,而Navigable map又实现了Map,而Map.copyOf无论如何都会返回所有<K,V>的Map。显然,“排序更安全”是这个问题的前提。事实上,原始地图都是带有自定义比较器的TreeMap,但一旦地图及其内容被构建,它们就是不可变的,因此寻求更紧凑、更快速的实现是有意义的。请参见下面关于排序的更多评论。 - ljm599
一个 java.util.Map 没有“顺序”..它只有键和值...如果需要顺序,您需要一个(更具体的)java.util.SortedMap..至少。 - xerx593
1
请参见下面的响应和分析,了解Map规范的含义。 - ljm599
3个回答

4
copyOf 方法的规范说明如下:

返回一个包含给定 Map 条目的不可修改 Map。

(强调为我所加)
这意味着只有条目被复制,没有其他内容。因此,这是预期的行为,而不是错误。
另一方面,正如 TreeMap 类的构造函数文档所建议的那样,在 评论中建议,它说:

构造一个新的树形映射,其中包含与指定排序映射相同的映射并使用相同的排序方式。该方法在线性时间内运行。

参数:

m - 要将其映射放置到此映射中的排序映射,并且要使用其比较器对此映射进行排序

(再次强调为我所加)

2
除了Federico在他的答案中所说的内容外,Map的Javadoc文档在“不可修改的映射”部分也说到:

  • 映射的迭代顺序是未指定并且可能会发生更改。

如果您需要一个不可修改的Map并且希望使用相同的顺序,则可以使用Collections.unmodifiableMap对Map进行包装。如果您想要一个副本,则只需像Federico所说创建一个新的TreeMap即可:

private static <K, V> SortedMap<K, V> copyOf(SortedMap<K, V> map) {
    return Collections.unmodifiableSortedMap(new TreeMap<>(map));
}

问题在于Map和SortedMap之间构成等价类的定义不同,这种差异可能会导致缺陷,详见下文。 - ljm599
如果您需要使用 equals,还有另一种选项可供使用,即将 SortedMap 复制到 LinkedHashMap 中:Collections.unmodifiableMap(new LinkedHashMap<>(map))。结果不再是 SortedMap,而是普通的 Map,但它确实保留了插入顺序,这是原始 SortedMap 提供的顺序。 - Rob Spoor

1
Map的API规范中,Map.get(Object key)说明了以下内容:
更正式地说,如果此映射包含从键k到值v的映射,使得Objects.equals(key, k),则此方法返回v;否则返回null。 (最多只能有一个这样的映射。)
SortedMap的API规范则说明:
插入到排序映射中的所有键都必须实现Comparable接口(或被指定比较器接受),并且
请注意,排序映射维护的顺序(无论是否提供显式比较器)必须与等号一致,如果要正确实现Map接口,则必须是一致的。
正是我在发布此问题时忘记了后面那段话。因此,即使这种行为很奇怪,也可以预期上述行为。因此,为了在MapSortedMap之间获得一致的行为,必须重写Object::equals以与使用的ComparableComparator一致。
这是一个相当严重的要求/限制,因为它意味着您只应该将具有比较器的映射与键组合使用,其中键的实现重写Object::equals以与定制比较器的版本一致。也就是说,原则上对于每个定制比较器,都应该有相应的定制键。我认为这一点很容易被忽视;我肯定错过了它。
这个要求的一个推论是,通常情况下,不是专门设计为某些定制比较器的定制键的对象不能用作具有定制比较器的映射(任何继承SortedMap的东西)的键,因为通常情况下,该映射将打破Map的契约。我认为整个情况都很棘手。我认为使用以下比较层次结构(包括Map在内的所有映射)会更加一致:
  • 如果存在,则为Comparator<T>,否则
  • 如果为提供的键类型定义,则为Comparable<T>,否则
  • 如果为提供的键类型重写,则为Object::equals,否则
  • 使用默认的Object::equals,即==
即,如果给定了一个比较器用于映射,就使用它;否则,如果键是可比较的,则使用它;否则使用键的equal(Object)方法,其默认实现为==。对于不允许定制比较器(实际上是任何不是SortedMap的东西)的Map实现,需要接受比较器(并在没有提供比较器时使用Comparable<T>,以定义等价类(而不是排序)。为此,定义"Equalator<T>"或"Equivalencer<T>"的概念可能很有用,以提供类型为T的键的必要专用等式(等价性)定义,存在于某些Map中。这避免了为不需要/不接受定制排序顺序的映射定义排序顺序的需要。比较器可以修改为继承Equalator<T>,以提供显式等价性以及排序顺序(一般来说,Comparator<T>已经暗示了等价性的隐含定义)。
这种对Map API规范的更改将避免为SortedMap的每个比较器定义专用键的需要,并允许在不破坏Map契约的情况下使用通用对象作为键。这也意味着相同的对象类型可以在不同的Map中用作键,同时利用不同的等价性定义,目前这是不容易实现的,因为Map受限于使用Object::equals来定义等价性。
我没有对未排序的映射使用不同等价类的用例,但我有很多使用定制比较器的用例,有时有、有时没有定制键,这使得我需要为映射定义比较器和一致的Object::equals,这是有问题的。在我的观点中,比较器应该足够了。忘记定义或使Object::equals的定义与映射的比较器不一致很容易导致奇怪的缺陷,这些缺陷可能远离原因并需要API调试才能理解。

1
你已经找到了主要问题。要将具有自定义比较器的 TreeMap 中的映射复制到类似于 HashMapMap.copyOf 的映射中,您需要提供 hashCodeequals 方法,这些方法定义与 compare(a,b)==0 相同的等价类。规范确实在“与 equals 一致”的某些间接词汇方面有所说明,但总体而言,它需要做得更好。 (还存在一些明显的错误,例如,TreeSet.add() 表示它使用 equals(),但实际上并没有。)不幸的是,Map(和 Set)接口不太可能按照您的建议进行改进。 - Stuart Marks
1
谢谢您的回复,斯图尔特。我想我可能会遇到一些语言上的困难,这使得这种更改非常困难,或者这种事情可以理解为优先级很低。无论如何,这是每个Java初学者课程中需要插入的内容8-)。 - ljm599

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接