使用自定义 hashCode 的 HashSet

HashSet using custom hashCode

有人能给我指出正确的方向吗:我想在不更改 hashCode()/equals() 方法的情况下自定义 HashSet

用法是拥有一组必须具有不同的一个(或多个)属性的对象。

因此,例如,对于此 Class:

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

我想要 UserNameSet,它只允许包含具有不同名称的用户。我不想覆盖 User 中的 hashCode 和 equals 方法,因为我仍然想区分具有相同名称但不同电子邮件的用户。

我想以某种方式“覆盖” hashCode()/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();
        }
    }
}

清洁高效

I would like to have UserNameSet which would allow to contain only users which have different name

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

封装集合并提供对集合的有限访问的方法比扩展集合更灵活、更简单,而且不会产生紧密耦合。

您可以在 “有效 Java”[=52] 一书中找到类似的建议,以及通过扩展现有集合使您的代码依赖于现有集合所带来的缺点示例=] 作者 Joshua Bloch,项目 优先考虑组合而不是继承

这就是 class 的样子:

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 成为 通用的 并且能够包装任何对象。

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

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 described in the 这样利用排序集合的方法可用于 相对较小的 对象数量。

需要强调的是,将数据存储在已排序的集合中是有成本的。它们变得越大,运行速度就越慢。 TreeSetRed-black tree 支持,大多数操作如基本 add()remove()contains() 操作需要 O(n) 时间,除了 edge-cases 当我们处理 lowest/highest key.

commons-collections 已经提供了一个 Equator 接口来执行您的建议:

public interface Equator<T> {
    boolean equate(T o1, T o2);
    int hash(T o);
}

但是,对基于赤道创建集合的直接支持是有限的。在 CollectionUtils.

中有一些涉及赤道的操作可用

但是你可以利用 Transformer to wrap your desired objects into ones that use an equator and then use all the support commons-collections provides for transformers. For example using 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));

ComparatorTreeSet

结合使用

作为 by Johannes Kuhn, you can get your desired behavior by using a NavigableSet(或SortedSet)。 无需自己发明class。

NavigableSet such as TreeSet may offer a constructor taking a Comparator 对象的实现。 Comparator 用于对集合的元素进行排序。

就我们在这个问题中的观点而言,Comparator 也用于决定接纳新的不同元素,而不是使用元素自己的 Object#equals 方法。

并且由于 TreeSet 中不涉及散列,因此无需担心覆盖 hashCode

我们可以很容易地定义我们的 Comparator 实现。为了方便,我们可以调用Comparator.comparing来做一个比较器的实现。我们通过为所需 name 字段的 getter 方法传递方法引用来定义比较器:User :: name.

您可以通过调用 thenComparing 向比较器添加更多条件。我把它留作 reader.

的练习

为简洁起见,让我们将您的 User class 定义为 record。我们只需声明成员字段的类型和名称。编译器隐式创建构造函数 getters、equals & hashCodetoString.

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 );

3 elements in setOfUsers = [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]]

3 elements in setOfUsers = [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 elements in 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 elements in 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 一致”,从而违反了 [=48= 的一般合同].

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