HashSet using custom hashCode

Viewed 173

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

Use Comparator with TreeSet

As commented by Johannes Kuhn, you can get your desired behavior by using a NavigableSet (or SortedSet). 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:

  • If we have failed in our goal of using only name for comparison, we would get 4 elements in this set.
  • If we have succeeded in using only name, then we should get 3 elements after having blocked admission of this interloper.

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.

Clean and Efficient

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

You can apply composition and create a class that would maintain a Map and delegate all calls to it.

The approach of encapsulating a collection and providing limited access to it is more flexible and less tedious than extending a collection, and also doesn't create a tight coupling.

Similar advice, and also examples of disadvantages that come with making your code dependent on the existing collection by extending it, you can find in the book "Effective Java" by Joshua Bloch, item Favor composition over inheritance.

That's how such a class might look like:

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
}

We can also make this class to be generic and capable of wrapping any object.

For that, we need to introduce an additional argument - a function that will be responsible for extracting the target property from the object.

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
}

And that's how it would be instantiated in the client code:

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

Concise and Simple

The approach of utilizing sorted collections like TreeSet described in the Basil's Bourque answer can be used for a relatively small number of objects.

It's important to emphasize that storing the data in sorted collections has a cost. The larger they become, the slower they operate. TreeSet is backed by the Red-black tree and most of the actions like basic add(), remove() and contains() operations require O(n) time, apart from the edge-cases when we deal with the lowest/highest key.

commons-collections already provides an Equator interface to do what you suggest:

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

However direct support for creating collections based on an equator is limited. There are a few operations involving an equator available in CollectionUtils.

However you could leverage 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));
Related