Проблема с компаратором и ТreeSet

Имеется класс Human и производный от него класс Student:

public class Human implements Comparable<Human> {

    private String name;
    private String surname;
    private String patronymic;
    private int age;

    public Human(String surname, String name, String patronymic, int age) {
        this.name = name;
        this.surname = surname;
        this.patronymic = patronymic;
        this.age = age;
    }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getSurname() { return surname; }
    public void setSurname(String surname) { this.surname = surname; }

    public String getPatronymic() { return patronymic; }
    public void setPatronymic(String patronymic) { this.patronymic = patronymic; }

    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Human human = (Human) o;
        return age == human.age && Objects.equals(name, human.name) && Objects.equals(surname, human.surname) && Objects.equals(patronymic, human.patronymic);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, surname, patronymic, age);
    }

    @Override
    public String toString() {
        return "Human{" +
                ", surname='" + surname + '\'' +
                "name='" + name + '\'' +
                ", patronymic='" + patronymic + '\'' +
                ", age=" + age +
                '}';
    }

    public String getFullName() {
        return surname + " " + name + " " + patronymic;
    }

    @Override
    public int compareTo(Human o2) {
        return this.getFullName().compareTo(o2.getFullName());
    }
}
//Student
public class Student extends Human {
    String faculty;

    public Student(String surname, String name, String patronymic, int age, String faculty) {
        super(surname, name, patronymic, age);
        this.faculty = faculty;
    }

    public String getFaculty() { return faculty; }
    public void setFaculty(String faculty) { this.faculty = faculty; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        Student student = (Student) o;
        return Objects.equals(faculty, student.faculty);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), faculty);
    }

    @Override
    public String toString() {
        return "Student{" +
                "faculty='" + faculty + '\'' +
                '}';
    }
}

Мне нужно написать метод, который будет сортировать элементы входного множества объектов, расширяющих Human по неубыванию ФИО без явного использования сортировки (задача на изучение JCF).

Я использую TreeSet и определяю компаратор внутри метода:

public static List<Human> buildSortedlist(Set<? extends Human> humans) {
    Set<Human> sortedSet = new TreeSet<>(new Comparator<Human>() {
        @Override
        public int compare(Human o1, Human o2) {
            int compareNames = o1.getFullName().compareTo(o2.getFullName());
            if (compareNames != 0) {
                return compareNames;
            } else {
                return o1.compareTo(o2);
            }
        }
    });
    sortedSet.addAll(humans);
    return new ArrayList<>(sortedSet);
}

Однако, если в сете есть люди с полностью одинаковым ФИО, например, "Иванов Иван Иванович 10 лет" и "Иванов Иван Иванович 60 лет", то данный метод оставит в списке только одного из них.

Не понимаю, в чем может быть проблема, подскажите, пожалуйста.


Ответы (2 шт):

Автор решения: Nowhere Man

Об этом собственно написано в документации TreeSet: в данной реализации для сравнения элементов используются методы compareTo / compare, а не стандартная пара методов hashCode / equals как в обычном множестве (Set)

Note that the ordering maintained by a set (whether or not an explicit comparator is provided) must be consistent with equals if it is to correctly implement the Set interface. (See Comparable or Comparator for a precise definition of consistent with equals.) This is so because the Set interface is defined in terms of the equals operation, but a TreeSet instance performs all element comparisons using its compareTo (or compare) method, so two elements that are deemed equal by this method are, from the standpoint of the set, equal. The behavior of a set is well-defined even if its ordering is inconsistent with equals; it just fails to obey the general contract of the Set interface.

То есть, для сортированного множества упорядочение должно соответствовать методу equals, как описано в документации интерфейса Comparator:

The ordering imposed by a comparator c on a set of elements S is said to be consistent with equals if and only if c.compare(e1, e2)==0 has the same boolean value as e1.equals(e2) for every e1 and e2 in S.

и интерфейса Comparable:

The natural ordering for a class C is said to be consistent with equals if and only if e1.compareTo(e2) == 0 has the same boolean value as e1.equals(e2) for every e1 and e2 of class C...

В представленном коде это требование соответствия нарушено, так как переопределённый компаратор сначала сравнивает два объекта Human по полному имени o1.getFullName().compareTo(o2.getFullName()), и в случае совпадения имён сравнивает объекты при помощи метода compareTo из реализации метода Comparable, в которой опять же выполняется сравнение только по полному имени, игнорируя другие свойства, которые используются в hashCode и equals для соответствующих классов (age для Human, faculty для Student).

Следовательно, придётся либо переделывать метод Human::compareTo, чтобы он учитывал поле age, либо переопределять компаратор с той же целью.

Переопределить компаратор можно гораздо лаконичнее с помощью фабричных методов Сomparator.comparing, Сomparator.thenComparing и др.

public static List<Human> buildSortedlist(Set<? extends Human> humans) {
    Set<Human> sortedSet = new TreeSet<>(Comparator
        .comparing(Human::getFullName)
        .thenComparing(Human::getAge)
    );
    sortedSet.addAll(humans);
    return new ArrayList<>(sortedSet);
}

Правда, и такой вариант не идеален, так как теперь могут теряться экземпляры "студентов" -- полных тёзок и сверстников с разных факультетов, чего можно избежать, "наколхозив" дополнительные сравнения:

public static List<Human> buildSortedlist(Set<? extends Human> humans) {
    Set<Human> sortedSet = new TreeSet<>(Comparator
        .comparing(Human::getFullName)
        .thenComparing(Human::getAge)
        .thenComparing(h -> h.getClass().getName())
        .thenComparing(h -> ((Student) h).getFaculty())
    );
    sortedSet.addAll(humans);
    return new ArrayList<>(sortedSet);
}

Также следует упомянуть о возможном решении данной задачи при помощи Stream API -- фактически, достаточно отсортировать стрим элементов исходного множества и преобразовать его в список:

public static <H extends Human> List<H> buildSortedlist(Set<H> humans) {
    return humans.stream()
        .sorted(Comparator
            .comparing(Human::getFullName)
            .thenComparing(Human::getAge)
            .thenComparing(h -> h.getClass().getName())
            .thenComparing(h -> ((Student) h).getFaculty())
        )
        .toList(); // или collect(Collectors.toList()) для Java 8..15
}

Похожие вопросы на основном SO:

→ Ссылка
Автор решения: Stanislav Volodarskiy

TreeSet не подходит потому что он не позволит тёзкам находится вместе в одном контейнере. Вы про это написали в вопросе, я повторяю за вами.

Помещать процедуры упорядочивающие объекты в сами объекты - тупиковый путь. Сложные объекты рано или поздно захочется упорядочивать различными способами. Поэтому метод compareTo мало полезен. Иногда у вас один, самый лучший порядок, как у чисел или строк. В сложных объектах самого лучшего порядка нет.

Метод equalshashCode), который сравнивает только ФИО и возраст - нонсенс. Два полных тёзки родившихся в один год - это не один и тот-же человек. Люди не обладают семантикой типа значения. Сложные типы-значения - это вообще редкие звери.

Я удалил из типов Human и Student все что связано с равенством и порядком. Как тогда сортировать? Через дополнительную структуру TreeMap, которая связывает полное имя со списком людей:

    private static List<Human> buildSortedlist(Set<? extends Human> humans) {
        TreeMap<String, List<Human>> map = new TreeMap<>();
        for (Human h : humans) {
            String name = h.getFullName();
            if (!map.containsKey(name)) {
                map.put(name, new ArrayList<Human>());
            }
            map.get(name).add(h);
        }
        List<Human> list = new ArrayList<>();
        for (List<Human> v : map.values()) {
            list.addAll(v);
        }
        return list;
    }

Это отлично работает и не требует вносить частный код, связанный с сортировкой, в сам тип. Если вы захотите сортировать людей и студентов по другому, вы напишете другую процедуру, которая по-другому будет вычислять ключи для сравнения и упорядочения объектов. Эти ключи могут быть простыми строками как получилось сейчас или быть сложными объектами, но это будут отдельные объекты, созданные для данного конкретного алгоритма сортировки.

Пример полного кода:

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;


public class Temp {
    private static class Human {
        protected String surname;
        protected String name;
        protected String patronymic;
        protected int age;

        public Human(String surname, String name, String patronymic, int age) {
            this.surname = surname;
            this.name = name;
            this.patronymic = patronymic;
            this.age = age;
        }

        public String getFullName() {
            return surname + " " + name + " " + patronymic;
        }

        @Override
        public String toString() {
            return "Human{" +
                "surname='" + surname + '\'' +
                ", name='" + name + '\'' +
                ", patronymic='" + patronymic + '\'' +
                ", age=" + age +
                '}'
            ;
        }
    }

    private static class Student extends Human {
        private String faculty;

        public Student(String surname, String name, String patronymic, int age, String faculty) {
            super(surname, name, patronymic, age);
            this.faculty = faculty;
        }

        @Override
        public String toString() {
            return "Student{" +
                "surname='" + surname + '\'' +
                ", name='" + name + '\'' +
                ", patronymic='" + patronymic + '\'' +
                ", age=" + age +
                ", faculty='" + faculty + '\'' +
                '}'
            ;
        }
    }

    private static List<Human> buildSortedlist(Set<? extends Human> humans) {
        TreeMap<String, List<Human>> map = new TreeMap<>();
        for (Human h : humans) {
            String name = h.getFullName();
            if (!map.containsKey(name)) {
                map.put(name, new ArrayList<Human>());
            }
            map.get(name).add(h);
        }
        List<Human> list = new ArrayList<>();
        for (List<Human> v : map.values()) {
            list.addAll(v);
        }
        return list;
    }

    public static void main(String... args) {
        Set<Human> set = new HashSet<>();
        Collections.addAll(
            set,
            new Human("a", "b", "c", 20),
            new Student("g", "h", "i", 30, "k"),
            new Human("d", "e", "f", 30)
        );
        for (Human h : set) {
            System.out.println(h);
        }
        System.out.println();
        for (Human h : buildSortedlist(set)) {
            System.out.println(h);
        }
    }
}
$ javac Temp.java && java Temp
Human{surname='a', name='b', patronymic='c', age=20}
Student{surname='g', name='h', patronymic='i', age=30, faculty='k'}
Human{surname='d', name='e', patronymic='f', age=30}

Human{surname='a', name='b', patronymic='c', age=20}
Human{surname='d', name='e', patronymic='f', age=30}
Student{surname='g', name='h', patronymic='i', age=30, faculty='k'}
→ Ссылка