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();
}
}
}
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:
name
for comparison, we would get 4 elements in this set.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.
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.