javaequalshashsethashcode

HashSet using custom hashCode


Could someone point me the right direction: I would like to have a custom HashSet without changing hashCode()/equals() methods.

Usage would be to have a Set of objects which must have different one attribute (or more).

So, for example, for this Class:

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

I would like to have UserNameSet which would allow to contain only users which have different name. I do not want to override the hashCode and equals method in User, because I still want to differentiate between users with same name but different email for example.

I would like to somehow "override" the hashCode()/equals() method just for this one HashMap.

EDITED

I have come up with this solution on first glance it works, could someone check it?

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

Solution

  • Use Comparator with TreeSet

    As commented by Johannes Kuhn, you can get your desired behavior by using a NavigableSet (or SortedSet) or, in Java 21+,SequencedSet (JEP 431). No need to invent your own class.

    Implementations of NavigableSet such as TreeSet may offer a constructor taking a Comparator object. That Comparator is used for sorting the elements of the set.

    To our point here in this Question, that Comparator is also used in deciding to admitting new distinct elements rather than using the elements’ own Object#equals method.

    And since there is no hashing involved in a TreeSet, there is no concern about overriding hashCode.

    We can easily define our Comparator implementation. For convenience, we can call Comparator.comparing to make a comparator implementation. We define the comparator by passing a method reference for the getter method of your desired name field: User :: name.

    You can add more criteria to your comparator by calling thenComparing. I leave that as an exercise for the reader.

    For brevity, let's define your User class as a record. We simply declare the type and name of member fields. The compiler implicitly creates the constructor, getters, equals & hashCode, and toString.

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

    Make some sample data.

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

    Define our set, a TreeSet.

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

    Populate our set with 3 elements. Verify 3 elements by dumping to console.

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

    Now we try to add another user with the same name but different values in the other fields.

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

    By default, a record decides on equality by comparing each and every member field. So:

    Dump to console.

    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]]

    We see in those results (a) sorting of the elements by name, and (b) Blocking of the second Alice, with the original Alice remaining.

    To see the alternate behavior, replace the setOfUsers definition with this:

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

    Running that edition of the code results in setOfUsers.size() being:

    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]]

    We see in those results (a) no particular sorting, and (b) the addition of a second "Alice", having increased the set from 3 elements to 4.

    Caveat

    One possible downside to my solution here is that we are violating the recommendation of the Javadoc of TreeSet to be “consistent with equals”, thereby violating the general contract of Set.

    I am not sure if that issue is problematic or not — I do not have enough perspective to form a judgement.