Что означает сравнение, сопоставимое с равными? Что может случиться, если мой класс не соблюдает этот принцип?

Из JavaDoc TreeMap:

Обратите внимание, что упорядочение, поддерживаемое сортированной картой (независимо от того, явный компаратор) должен быть согласован с равными, если эта сортированная карта - это правильно реализовать интерфейс карты. (Видеть Сравнительный или компаратор для точного определения, согласующегося с равно). Это связано с тем, что интерфейс Map определяется в терминах операция равенства, но карта выполняет все ключевые сравнения, используя compareTo (или compare), поэтому два ключа, которые считаются равными этот метод, с точки зрения сортированной карты, равен. поведение сортированной карты хорошо определено, даже если ее упорядочение несовместим с равными; он просто не подчиняется генеральному контракту интерфейса карты.

Может ли кто-нибудь дать конкретный пример для демонстрации проблемы, которая может возникнуть, если упорядочение не соответствует равным? Возьмем, например, пользовательский класс, который имеет естественный порядок, т.е. Реализует Comparable. Также все внутренние классы в JDK поддерживают этот инвариант?

Ответ 1

Контракт интерфейса Comparable допускает несовместимое поведение:

Настоятельно рекомендуется (хотя и не обязательно), чтобы естественные порядки были согласованы с равными.

Таким образом, теоретически возможно, что класс в JDK имел compareTo, не соответствующий equals. Хорошим примером является BigDecimal.

Ниже приведен надуманный пример компаратора, который не согласуется с равными (в основном он говорит, что все строки равны).

Вывод:

размер: 1
content: {a = b}

public static void main(String[] args) {
    Map<String, String> brokenMap = new TreeMap<String, String> (new Comparator<String>() {

        @Override
        public int compare(String o1, String o2) {
            return 0;
        }
    });

    brokenMap.put("a", "a");
    brokenMap.put("b", "b");
    System.out.println("size: " + brokenMap.size());
    System.out.println("content: " + brokenMap);
}

Ответ 2

Скажем, у нас есть этот простой класс Student, реализующий Comparable<Student>, но не переопределяющий equals()/hashCode(). Конечно, equals() не согласуется с compareTo() - два разных ученика с одинаковыми age не равны:

class Student implements Comparable<Student> {

    private final int age;

    Student(int age) {
        this.age = age;
    }

    @Override
    public int compareTo(Student o) {
        return this.age - o.age;
    }

    @Override
    public String toString() {
        return "Student(" + age + ")";
    }
}

Мы можем безопасно использовать его в TreeMap<Student, String>:

Map<Student, String> students = new TreeMap<Student, String>();
students.put(new Student(25), "twenty five");
students.put(new Student(22), "twenty two");
students.put(new Student(26), "twenty six");
for (Map.Entry<Student, String> entry : students.entrySet()) {
    System.out.println(entry);
}
System.out.println(students.get(new Student(22)));

Результаты легко предсказать: ученики хорошо сортируются в соответствии с их возрастом (несмотря на то, что они вставлены в другом порядке), и выбор ученика, использующего ключ new Student(22), также работает и возвращает "twenty two". Это означает, что мы можем безопасно использовать класс Student в TreeMap.

Однако измените students на HashMap, и все будет плохо:

Map<Student, String> students = new HashMap<Student, String>();

Очевидно, что перечисление элементов возвращает "случайный" порядок из-за хэширования - этот штраф, он не нарушает ни одного контракта Map. Но последнее утверждение полностью нарушено. Поскольку HashMap использует equals()/hashCode() для сравнения экземпляров, извлечение значения с помощью ключа new Student(22) завершается с ошибкой и возвращает null!

Это то, что пытается объяснить JavaDoc: такие классы будут работать с TreeMap, но могут не работать с другими реализациями Map. Обратите внимание, что операции Map документируются и определяются в терминах equals()/hashCode(), например. containsKey():

[...] возвращает true тогда и только тогда, когда эта карта содержит отображение для ключа k такое, что (key==null ? k==null : key.equals(k))

Таким образом, я не считаю, что существуют стандартные классы JDK, которые реализуют Comparable, но не реализуют пару equals()/hashCode().

Ответ 3

Вот еще один пример того, насколько важна согласованность с равными И общим порядком.

Скажем, у нас есть объект MyObject, который имеет два поля: id и quantity. id, как следует из его названия, является естественным ключом объекта, а quantity является только атрибутом.

public class MyObject {
  int id;
  int quantity;
  ...
}

Предположим, что мы хотим использовать коллекции MyObject, отсортированные по quantity по убыванию. Первым компаратором, который мы можем записать, является:

Comparator<MyObject> naiveComp = new Comparator<MyObject>() {
  @Override
  public int compare(MyObject o1, MyObject o2) {
    return o2.quantity - o1.quantity;
  }
};

Использование экземпляров MyObject, оснащенных этим компаратором в TreeMap/TreeSet, терпит неудачу, потому что компаратор не соответствует равным (см. полный код ниже). Пусть он согласуется с равенствами:

Comparator<MyObject> slightlyBetterComp = new Comparator<MyObject>() {
  @Override
  public int compare(MyObject o1, MyObject o2) {
    if (o1.equals(o2)) {
      return 0;
    }
    if (o1.quantity == o2.quantity) {
      return -1; // never 0
    }
    return o2.quantity - o1.quantity; // never 0
  }
};

Однако это снова не вписывается в TreeSet/TreeMap! (см. полный код ниже) Это связано с тем, что отношение упорядочения не total, т.е. Никакие два объекта не могут быть строго привязаны к отношениям упорядочения. В этом компараторе, когда поля quantity равны, результирующий порядок не определен.

Лучшим компаратором будет:

Comparator<MyObject> betterComp = new Comparator<MyObject>() {
  @Override
  public int compare(MyObject o1, MyObject o2) {
    if (o1.equals(o2)) {
      return 0;
    }
    if (o1.quantity == o2.quantity) {
      return o1.id - o2.id; // never 0
    }
    return o2.quantity - o1.quantity; // never 0
  }
};

Этот компаратор обеспечивает:

  • когда compareTo возвращает 0, это означает, что два объекта: equal (начальная проверка для равенства)
  • все элементы полностью упорядочены, используя id в качестве поля дискриминантного упорядочения, когда quantity равны

Полный тестовый код:

package treemap;

import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;

public class MyObject {
  int id;
  int quantity;

  public MyObject(int id, int quantity) {
    this.id = id;
    this.quantity = quantity;
  }

  @Override
  public int hashCode() {
    int hash = 7;
    hash = 97 * hash + this.id;
    return hash;
  }

  @Override
  public boolean equals(Object obj) {
    if (obj == null) {
      return false;
    }
    if (getClass() != obj.getClass()) {
      return false;
    }
    final MyObject other = (MyObject) obj;
    if (this.id != other.id) {
      return false;
    }
    return true;
  }

  @Override
  public String toString() {
    return "{" + id + ", " + quantity + "}";
  }

  public static void main(String[] args) {
    String format = "%30.30s: %s\n";
    Map<MyObject, Object> map = new HashMap();
    map.put(new MyObject(1, 100), 0);
    map.put(new MyObject(2, 100), 0);
    map.put(new MyObject(3, 200), 0);
    map.put(new MyObject(4, 100), 0);
    map.put(new MyObject(5, 500), 0);
    System.out.printf(format, "Random Order", map.keySet());

    // Naive non-consisten-with-equal and non-total comparator
    Comparator<MyObject> naiveComp = new Comparator<MyObject>() {
      @Override
      public int compare(MyObject o1, MyObject o2) {
        return o2.quantity - o1.quantity;
      }
    };
    Map<MyObject, Object> badMap = new TreeMap(naiveComp);
    badMap.putAll(map);
    System.out.printf(format, "Non Consistent and Non Total", badMap.keySet());

    // Better consisten-with-equal but non-total comparator
    Comparator<MyObject> slightlyBetterComp = new Comparator<MyObject>() {
      @Override
      public int compare(MyObject o1, MyObject o2) {
        if (o1.equals(o2)) {
          return 0;
        }
        if (o1.quantity == o2.quantity) {
          return -1; // never 0
        }
        return o2.quantity - o1.quantity; // never 0
      }
    };
    Map<MyObject, Object> slightlyBetterMap = new TreeMap(naiveComp);
    slightlyBetterMap.putAll(map);
    System.out.printf(format, "Non Consistent but Total", slightlyBetterMap.keySet());

    // Consistent with equal AND total comparator
    Comparator<MyObject> betterComp = new Comparator<MyObject>() {
      @Override
      public int compare(MyObject o1, MyObject o2) {
        if (o1.equals(o2)) {
          return 0;
        }
        if (o1.quantity == o2.quantity) {
          return o1.id - o2.id; // never 0
        }
        return o2.quantity - o1.quantity; // never 0
      }
    };
    Map<MyObject, Object> betterMap = new TreeMap(betterComp);
    betterMap.putAll(map);
    System.out.printf(format, "Consistent and Total", betterMap.keySet());
  }
}

Вывод:

                  Random Order: [{5, 500}, {4, 100}, {3, 200}, {2, 100}, {1, 100}]
  Non Consistent and Non Total: [{5, 500}, {3, 200}, {4, 100}]
      Consistent but Not Total: [{5, 500}, {3, 200}, {4, 100}]
          Consistent and Total: [{5, 500}, {3, 200}, {1, 100}, {2, 100}, {4, 100}]

Вывод:

Хотя я считаю, что вполне законно отделять идентичность от упорядочения концептуально. Например, в терминах реляционных баз данных:

select * from MyObjects order by quantity

работает отлично. Мы не заботимся об идентичности объекта здесь, и мы не хотим, чтобы общее упорядочение

Однако из-за ограничений в реализации коллекций на основе дерева необходимо убедиться, что любой компаратор, который они пишут:

  • является согласованностью с равными
  • обеспечивает общий порядок над всеми возможными объектами