使用自定义hashCode的HashSet

4

有人可以给我指一下正确的方向吗: 我想要一个自定义的 HashSet,但不想改变 hashCode()/equals() 方法。

使用场景是需要一个对象集合,这些对象必须具有不同的一个或多个属性。

例如,对于这个类:

@FieldDefaults(level = AccessLevel.PRIVATE)
@Getter @Setter
public class User{
    String name;
    String email;
    String age;
}

我想要有一个UserNameSet,它只允许包含具有不同姓名的用户。我不想在User类中重写hashCode和equals方法,因为我仍然希望区分具有相同名称但电子邮件不同的用户。

我想要以某种方式“覆盖”这个HashMaphashCode()/equals()方法,仅限于这一个HashMap

编辑:

我看到了这个解决方案,一开始似乎可以工作,能否有人检查一下?

package com.znamenacek.debtor.util;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.experimental.FieldDefaults;

import java.util.*;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.IntFunction;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@FieldDefaults(level = AccessLevel.PRIVATE)
public class CustomizableHashSet<T> implements Set<T> {
    Function<T, Integer> customHashCode = Object::hashCode;
    HashSet<ClassWrapper> storage = new HashSet<>();

    public CustomizableHashSet(Function<T, Integer> customHashCode) {
        this.customHashCode = customHashCode;
    }

    public CustomizableHashSet() {}

    public CustomizableHashSet(Collection<? extends T> c, Function<T, Integer> customHashCode) {
        storage = new HashSet<>(c.stream().map(ClassWrapper::new).toList());
        this.customHashCode = customHashCode;
    }

    public CustomizableHashSet(Collection<? extends T> c) {
        storage = new HashSet<>(c.stream().map(ClassWrapper::new).toList());
    }

    public CustomizableHashSet(int initialCapacity, float loadFactor, Function<T, Integer> customHashCode) {
        storage = new HashSet<>(initialCapacity, loadFactor);
        this.customHashCode = customHashCode;
    }

    public CustomizableHashSet(int initialCapacity, float loadFactor) {
        storage = new HashSet<>(initialCapacity, loadFactor);
    }

    public CustomizableHashSet(int initialCapacity, Function<T, Integer> customHashCode) {
        storage = new HashSet<>(initialCapacity);
        this.customHashCode = customHashCode;
    }

    public CustomizableHashSet(int initialCapacity) {
        storage = new HashSet<>(initialCapacity);
    }

    @Override
    public Iterator<T> iterator() {
        return storage.stream().map(ClassWrapper::get).iterator();
    }

    @Override
    public int size() {
        return storage.size();
    }

    @Override
    public boolean isEmpty() {
        return storage.isEmpty();
    }

    @Override
    public boolean contains(Object o) {
        return storage.stream().map(ClassWrapper::get).collect(Collectors.toSet()).contains(o);
    }

    @Override
    public boolean add(T t) {
        return storage.add(new ClassWrapper(t));
    }

    @Override
    public boolean remove(Object o) {
        boolean returnValue;
        var storageContent = storage.stream().map(ClassWrapper::get).collect(Collectors.toSet());
        returnValue = storageContent.remove(o);
        storage = storageContent.stream().map(ClassWrapper::new).collect(Collectors.toCollection(HashSet::new));

        return returnValue;
    }

    @Override
    public void clear() {
        storage.clear();
    }

    @Override
    public Object clone() {
        throw new UnsupportedOperationException();
    }

    @Override
    public Spliterator<T> spliterator() {
        return storage.stream().map(ClassWrapper::get).spliterator();
    }

    @Override
    public Object[] toArray() {
        return storage.stream().map(ClassWrapper::get).toArray();
    }

    @Override
    public <T1> T1[] toArray(T1[] a) {
        return storage.stream().map(ClassWrapper::get).collect(Collectors.toSet()).toArray(a);
    }

    @Override
    public boolean removeAll(Collection<?> c) {
        boolean returnValue;
        var storageContent = storage.stream().map(ClassWrapper::get).collect(Collectors.toSet());
        returnValue = storageContent.removeAll(c);
        storage = storageContent.stream().map(ClassWrapper::new).collect(Collectors.toCollection(HashSet::new));

        return returnValue;
    }

    @Override
    public boolean containsAll(Collection<?> c) {
        boolean returnValue;
        var storageContent = storage.stream().map(ClassWrapper::get).collect(Collectors.toSet());
        returnValue = storageContent.containsAll(c);
        storage = storageContent.stream().map(ClassWrapper::new).collect(Collectors.toCollection(HashSet::new));

        return returnValue;
    }

    @Override
    public boolean addAll(Collection<? extends T> c) {
        return storage.addAll(c.stream().map(ClassWrapper::new).collect(Collectors.toSet()));
    }

    @Override
    public boolean retainAll(Collection<?> c) {
        boolean returnValue;
        var storageContent = storage.stream().map(ClassWrapper::get).collect(Collectors.toSet());
        returnValue = storageContent.retainAll(c);
        storage = storageContent.stream().map(ClassWrapper::new).collect(Collectors.toCollection(HashSet::new));

        return returnValue;
    }

    @Override
    public String toString() {
        return storage.stream().map(ClassWrapper::get).collect(Collectors.toSet()).toString();
    }

    @Override
    public <T1> T1[] toArray(IntFunction<T1[]> generator) {
        return storage.stream().map(ClassWrapper::get).collect(Collectors.toSet()).toArray(generator);
    }

    @Override
    public boolean removeIf(Predicate<? super T> filter) {
        boolean returnValue;
        var storageContent = storage.stream().map(ClassWrapper::get).collect(Collectors.toSet());
        returnValue = storageContent.removeIf(filter);
        storage = storageContent.stream().map(ClassWrapper::new).collect(Collectors.toCollection(HashSet::new));

        return returnValue;
    }

    @Override
    public Stream<T> stream() {
        return storage.stream().map(ClassWrapper::get);
    }

    @Override
    public Stream<T> parallelStream() {
        return storage.parallelStream().map(ClassWrapper::get);
    }

    @Override
    public void forEach(Consumer<? super T> action) {
        storage.stream().map(ClassWrapper::get).forEach(action);
    }

    @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
    @AllArgsConstructor
    public class ClassWrapper{
        T object;

        @Override
        public int hashCode() {
            return customHashCode.apply(object);
        }

        @Override
        public boolean equals(Object obj) {
            if(this == obj) return true;

            if(obj == null) return false;

            return hashCode() == obj.hashCode();
        }

        public T get(){
            return object;
        }

        @Override
        public String toString() {
            return "" + hashCode() + " - " + object.toString();
        }
    }
}

1
如果不重写hashCode,这个方法无法与set一起使用。但是你可以使用HashMap<String, User>,将名称映射到完整的用户,然后从该映射中获取值。 - tobias_k
3
你可以编写另一个类,简单地包装一个“User”实例,并按你想要的方式实现“equals”和“hashCode”。或者为“User”专门编写自己的Set实现(不必实现“Set”)。或者使用“Map<String, User>”作为集合。或者可能有其他我没有考虑到的想法。 - Slaw
1
@Slaw 我认为UserDecorator(带有重写的hashCode()equals())听起来很合理。 - Elliott Frisch
1
如果不需要使用 HashSetTreeSet 有一个可以接受自定义比较器的构造函数。 - Johannes Kuhn
1
@JakubZnamenáček 如果你想要对你写的代码进行反馈,最好去 https://codereview.stackexchange.com 发帖询问。 - SpaceTrucker
显示剩余4条评论
3个回答

5

使用ComparatorTreeSet

正如Johannes Kuhn在评论中所述,您可以通过使用NavigableSet(或SortedSet)来获得所需的行为。 无需发明自己的类。

TreeSet等实现NavigableSet的类可能提供一个带有Comparator对象的构造函数。该Comparator用于对集合的元素进行排序。

在这个问题中,该Comparator也用于决定是否使用元素自身的Object#equals方法来接受新的不同元素。

由于TreeSet中没有涉及哈希,所以不必担心覆盖hashCode

我们可以轻松定义我们的Comparator实现。为了方便,我们可以调用Comparator.comparing来创建一个比较器实现。我们通过传递您要排序的name字段的getter方法的方法引用来定义比较器:User :: name

您可以通过调用thenComparing将更多条件添加到您的比较器中。我把这留给读者作为练习。

出于简洁起见,让我们将您的User类定义为一个记录。我们只需声明成员字段的类型和名称即可。编译器隐式创建构造函数、getter、equalshashCode,以及toString

record User( String name , String email , int age ) { }

生成一些示例数据。

List < User > listOfUsers =
        List.of(
                new User( "Bob" , "bob@x.com" , 7 ) ,
                new User( "Alice" , "alice@x.com" , 42 ) ,
                new User( "Carol" , "carol@x.com" , 77 )
        );

定义一个 TreeSet 集合。

NavigableSet < User > setOfUsers = new TreeSet <>( Comparator.comparing( User :: name )  );

将我们的集合填充为3个元素。通过控制台输出验证是否有3个元素。

setOfUsers.addAll( listOfUsers );
System.out.println( setOfUsers.size() + " elements in setOfUsers = " + setOfUsers );

现在我们尝试添加另一个用户,其名称相同但其他字段的值不同。

setOfUsers.add( new User( "Alice" , "a@aol.com" , -666  ) );

默认情况下,record 通过比较每个成员字段来判断相等。因此:

  • 如果我们未能达到只使用 name 进行比较的目标,则在该集合中会有4个元素。
  • 如果我们成功地只使用 name,那么在阻止该插入者后,应该只剩下3个元素。

转储到控制台。

System.out.println( setOfUsers.size() + " elements in setOfUsers = " + setOfUsers );

setOfUsers中有3个元素= [User[name=Alice,email=alice@x.com,age=42],User[name=Bob,email=bob@x.com,age=7],User[name=Carol,email=carol@x.com,age=77]]

setOfUsers中有3个元素= [User[name=Alice,email=alice@x.com,age=42],User[name=Bob,email=bob@x.com,age=7],User[name=Carol,email=carol@x.com,age=77]]

我们可以看到这些结果中(a)元素按名称排序,(b)第二个"Alice"被阻塞,而原始的"Alice"仍然存在。

要查看替代行为,请将"setOfUsers"定义替换为以下内容:

Set < User > setOfUsers = new HashSet <>();

运行这个代码版本将导致setOfUsers.size()为:

3个元素在setOfUsers中=[User[name=Bob, email=bob@x.com, age=7], User[name=Carol, email=carol@x.com, age=77], User[name=Alice, email=alice@x.com, age=42]]

4个元素在setOfUsers中=[User[name=Bob, email=bob@x.com, age=7], User[name=Carol, email=carol@x.com, age=77], User[name=Alice, email=alice@x.com, age=42], User[name=Alice, email=a@aol.com, age=-666]]

我们可以看到这些结果中(a)没有特定的排序,(b)第二个"Alice"的添加,将集合从 3 个元素增加到 4 个元素。

注意事项

我的解决方法可能有一个缺点,就是我们违反了TreeSet的Javadoc建议与 "equals"一致,从而违反了Set的通用契约。

我不确定这个问题是否有问题 - 我没有足够的视角来形成判断。


嗨,巴西尔,这正是我在寻找的,与 HashSet 相比有什么优缺点吗?我读过 HashSet 在检索对象方面应该非常快,但对于迭代它们并不那么快。ThreeSet 是怎样的情况,有什么需要我知道的吗? - Jakub Znamenáček
顺便提一句,我仍然感到惊讶的是,Java 中没有 HashSet 的实现可以接受 lambda 表达式形式的自定义 hashCode 和 equals 函数。 - Jakub Znamenáček
@JakubZnamenáček TreeSet 的真正目的是按排序顺序处理键。在执行排序并多次利用排序时,NavigableSet 变得更加高效。我们在这里将其目的弯曲到了与 name 字段一起使用的需要上。因此,我们没有通过维护排序顺序获得任何好处。为了进一步考虑效率,我不知道——您必须检查 OpenJDK 代码库中实现的源代码。 - Basil Bourque
2
关于“不一致的equals”:Stuart Marks做了一个名为Collection Corner Cases的精彩演讲,在其中他解释了(除其他事项外)。 - Johannes Kuhn

3

简洁高效

我想要一个UserNameSet,它只允许包含名字不同的用户。

您可以应用组合并创建一个类来维护Map,并将所有调用委托给它。

封装集合并提供有限访问权限的方法比扩展集合更加灵活,不那么繁琐,并且不会创建紧密耦合。

关于通过扩展现有集合使代码依赖于它所带来的缺点,以及类似的建议和示例,您可以在 "Effective Java" 这本书中找到,作者是Joshua Bloch,条款是优先选择组合而非继承

这就是这样一个类可能的样子:

class UserNameSet {
    private Map<String, User> userByName = new HashMap<>();
    
    public User add(User user) {
        return userByName.put(user.getName(), user); // or `putIfAbsent()` if you want to retain the previously added user
    }
    
    public User remove(User user) {
        return userByName.remove(user.getName());
    }
    
    public boolean contains(User user) {
        return userByName.containsValue(user.getName());
    }
    
    public User remove(String name) {
        return userByName.remove(name);
    }
    
    public boolean contains(String name) {
        return userByName.containsKey(name);
    }
    
    // all other methods that are required
}

我们还可以使这个类成为通用的,可以包装任何对象。

为此,我们需要引入一个额外的参数 - 一个函数,负责从对象中提取目标属性。

class MyCustomSet<K, V> {
    private Map<K, V> userByName = new HashMap<>();
    private Function<V, K> keyExtractor;
    
    public MyCustomSet(Function<V, K> keyExtractor) {
        this.keyExtractor = keyExtractor;
    }
    
    public V add(V user) {
        return userByName.put(keyExtractor.apply(user), user); // or `putIfAbsent()` if you want to retain the previously added user
    }
    
    public V remove(V user) {
        return userByName.remove(keyExtractor.apply(user));
    }
    
    public boolean contains(V user) {
        return userByName.containsValue(user);
    }
    
    public V removeByKey(K name) {
        return userByName.remove(name);
    }
    
    public boolean containsKey(K name) {
        return userByName.containsKey(name);
    }
    
    // all other methods that are required
}

这就是在客户端代码中实例化的方式:

MyCustomSet<String, User> uniqueNameUsers = new MyCustomSet<>(User::getName);

简洁易懂

使用像TreeSet这样的排序集合方法(在Basil's Bourque回答中描述)可用于相对较少数量的对象。

需要强调的是,将数据存储在排序集合中是有成本的。它们越大,操作速度越慢。 TreeSet红黑树支持,大多数操作(如add()remove()contains()等基本操作)除了处理最高/最低键等特殊情况外,都需要O(n)时间。


评论已被移至聊天室,请勿在此处继续讨论。在此评论之前,请查看评论的目的。通常不请求澄清或建议改进的评论应作为答案、在[元数据]中或在[聊天室]中发布。继续讨论的评论可能会被删除。 - Dharman

1
commons-collections已经提供了一个Equator接口来实现您建议的功能:
public interface Equator<T> {
    boolean equate(T o1, T o2);
    int hash(T o);
}


然而,基于赤道创建集合的直接支持是有限的。CollectionUtils 中有一些涉及赤道的操作可用。
但是,您可以利用Transformer将所需对象包装为使用赤道的对象,然后使用commons-collections提供的所有转换器支持。例如,可以使用SetUtils.transformedSet
class EquatorWrapper<T> {
    private final Class<T> clazz;
    private final T wrapped;
    private final Equator<T> equator;

    public EquatorWrapper(Class<T> clazz, T wrapped, Equator<T> equator) {
        this.clazz = clazz;
        this.wrapped = wrapped;
        this.equator = equator;
    }

    @Override
    public boolean equals(Object obj) {
        if (clazz.isInstance(obj)) {
            return equator.equate(wrapped, clazz.cast(obj));
        }
        return false;
    }

    @Override
    public int hashCode() {
        return equator.hash(wrapped);
    }
}

class EquatorTransformer<T> implements Transformer<T, Object> {
    private final Class<T> clazz;
    private final Equator<T> equator;

    public EquatorTransformer(Class<T> clazz, Equator<T> equator) {
        this.clazz = clazz;
        this.equator = equator;
    }
        
    @Override
    public Object transform(T input) {
        return new EquatorWrapper<>(clazz, input, equator);
    }
}

SetUtils.transformedSet(someSet, EquatorTransformer.of(someEquator, SomeClazz.clazz));

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