web-dev-qa-db-fra.com

Comparateur adapté à TreeSet en l'absence de champ distinctif

Supposons que ma classe n'implémente pas l'interface Comparable comme

class Dummy {
}

et une collection d'instances de cette classe plus une fonction externe à la classe qui permet de comparer partiellement ces instances (une carte sera utilisée à cet effet ci-dessous):

Collection<Dummy> col = new ArrayList<>();
Map<Dummy, Integer> map = new HashMap<>();
for (int i = 0; i < 12; i++) {
    Dummy d = new Dummy();
    col.add(d);
    map.put(d, i % 4);
}

Maintenant, je veux trier cette collection en utilisant la classe TreeSet avec un comparateur personnalisé:

TreeSet<Dummy> sorted = new TreeSet<>(new Comparator<Dummy>() {
    @Override
    public int compare(Dummy o1, Dummy o2) {
        return map.get(o1) - map.get(o2);
    }
});
sorted.addAll(col);

Le résultat est évidemment insatisfaisant (contient moins d'éléments que la collection initiale). En effet, un tel comparateur n’est pas compatible avec equals, c’est-à-dire qu’il renvoie parfois 0 pour des éléments non égaux. Ma prochaine tentative a été de changer la méthode compare du comparateur en

@Override
public int compare(Dummy o1, Dummy o2) {
    int d = map.get(o1) - map.get(o2);
    if (d != 0)
        return d;
    if (o1.equals(o2))
        return 0;
    return 1; // is this acceptable?
}

Cela donne apparemment le résultat souhaité pour cet exemple de démonstration simple, mais je doute toujours: est-il correct de toujours renvoyer 1 pour des objets inégaux (mais indiscernables sur la carte)? Une telle relation enfreint toujours le contact général pour la méthode Comparator.compare() car sgn(compare(x, y)) == -sgn(compare(y, x)) est généralement mauvais. Dois-je vraiment mettre en œuvre un total correct de commande pour que TreeSet fonctionne correctement ou que ce qui précède est suffisant? Comment faire cela lorsqu'une instance n'a pas de champs à comparer?

Pour plus d'exemples concrets, imaginez qu'au lieu de Dummy, vous ayez un paramètre de type T d'une classe générique. T peut avoir certains champs et implémenter la méthode equals() par leur intermédiaire, mais vous ne les connaissez pas et vous devez encore trier les instances de cette classe en fonction d'une fonction externe. Est-ce possible avec l'aide de TreeSet?

Modifier

Utiliser System.identityHashCode() est une excellente idée, mais il y a (pas si petit) risque de collision.

Outre la possibilité d’une telle collision, il existe un autre pitfall. Supposons que vous avez 3 objets: a, b, c tels que map.get(a) = map.get(b) = map.get(c) (ici = n'est pas une affectation mais l'égalité mathématique), identityHashCode(a) < identityHashCode(b) < identityHashCode(c), a.equals(c) est vrai, mais a.equals(b) (et donc c.equals(b)) est faux. Après avoir ajouté ces 3 éléments à une TreeSet dans cet ordre: a, b, c, vous pouvez vous retrouver dans une situation où ils ont tous été ajoutés à l'ensemble, ce qui contredit le comportement prescrit de l'interface Set - elle ne doit pas contenir des éléments égaux. Comment gérer ça?

En outre, il serait bon que quelqu'un connaissant la mécanique TreeSet m'explique ce que le terme "bien défini" dans la phrase "Le comportement d'un ensemble est bien défini l'ordre est incohérent avec equals " from TreeSet javadoc mean.

10
John McClane

À moins que vous n'ayez une quantité absolument énorme d'objets factices et que vous n'ayez vraiment pas de chance, vous pouvez utiliser System.identityHashCode()pour casser les attaches:

Comparator.<Dummy>comparingInt(d -> map.get(d))
          .thenComparingInt(System::identityHashCode)

Votre comparateur n'est pas acceptable car il viole le contrat: vous avez d1> d2 et d2> d1 en même temps s'ils ne sont pas égaux et ne partagent pas la même valeur sur la carte.

6
JB Nizet

Cette réponse ne couvre que le premier exemple de la question. Le reste de la question, ainsi que les diverses modifications apportées, méritent à mon avis une réponse plus satisfaisante dans le cadre de questions distinctes et ciblées.

Le premier exemple définit 12 instances de Dummy, crée une carte qui mappe chaque instance sur une Integer dans la plage [0, 3], puis ajoute les 12 instances Dummy à un TreeSet. This TreeSet est fourni avec un comparateur qui utilise la carte Dummy-to-Integer. Le résultat est que la variable TreeSet ne contient que quatre des instances Dummy. L'exemple se termine par la déclaration suivante:

Le résultat est évidemment insatisfaisant (contient moins d'éléments que la collection initiale). En effet, un tel comparateur n’est pas cohérent avec égal à égal, c’est-à-dire qu’il renvoie parfois 0 pour des éléments non égaux.

Cette dernière phrase est incorrecte. Le résultat contient moins d'éléments que ceux insérés, car le comparateur considère que la plupart des instances sont des doublons et qu'elles ne sont donc pas insérées dans l'ensemble. La méthode equals n'entre pas du tout dans la discussion. Par conséquent, le concept de "compatible avec égaux" n'est pas pertinent pour cette discussion. TreeSet n'appelle jamais equals. Le comparateur est la seule chose qui détermine l’appartenance à TreeSet.

Cela semble être un résultat insatisfaisant, mais uniquement parce que nous savons qu'il existe 12 exemples - variables - différents. Cependant, Dummy ne "sait" pas qu'ils sont distincts. Il sait seulement comment comparer les instances TreeSet à l'aide du comparateur. Quand il le fait, il constate que plusieurs sont des doublons. En d’autres termes, le comparateur renvoie parfois 0 même s’il est appelé avec des instances Dummy que nous croyons distinctes. C'est pourquoi seules quatre instances Dummy se retrouvent dans la Dummy.

Je ne suis pas tout à fait sûr du résultat souhaité, mais il semble que le résultat --variable - devrait contenir les 12 instances ordonnées par les valeurs de la carte Dummy-to-Integer. Ma suggestion était d'utiliser la fonction Ordering.arbitrary() de Guava, qui fournit un comparateur permettant de distinguer les éléments distincts mais égaux par ailleurs, tout en satisfaisant le contrat général de Comparator. Si vous créez le TreeSet comme ceci:

SortedSet<Dummy> sorted = new TreeSet<>(Comparator.<Dummy>comparingInt(map::get)
                                                  .thenComparing(Ordering.arbitrary()));

le résultat sera que la variable TreeSet contient les 12 instances TreeSet, triées par la valeur TreeSet de la carte, et avec Dummy les instances qui mappent sur la même valeur, ordonnées arbitrairement.

Dans les commentaires, vous avez indiqué que la doc Ordering.arbitrary "met en garde de manière non équivoque contre son utilisation dans Integer". Ce n'est pas tout à fait juste; ce doc dit,

Dans la mesure où l'ordre est basé sur l'identité, il n'est pas "cohérent avec Object.equals (Object)" tel que défini par Comparator. Soyez prudent lorsque vous créez un SortedSet ou SortedMap à partir de celui-ci, car la collection résultante ne se comportera pas exactement selon les spécifications.

L'expression "ne se comporte pas exactement conformément à la spécification" signifie en réalité qu'il se comportera "étrangement" comme décrit dans la documentation de classe de Dummy :

L'ordonnance imposée par un comparateur c sur un ensemble d'éléments S est dite cohérente avec égaux si et seulement si c.compare(e1, e2)==0 a la même valeur booléenne que e1.equals(e2) pour chaque e1 et e2 de S.

Vous devez faire preuve de prudence lorsque vous utilisez un comparateur capable d'imposer un ordre incohérent avec égaux pour ordonner un ensemble trié (ou une carte triée). Supposons qu'un ensemble trié (ou une carte triée) avec un comparateur explicite c soit utilisé avec des éléments (ou des clés) tirés d'un ensemble S. Si l'ordre imposé par c sur S est incohérent avec les égaux, l'ensemble trié (ou la carte triée) se comporter "étrangement". En particulier, l'ensemble trié (ou la carte triée) violera le contrat général pour l'ensemble (ou la carte), défini en termes de SortedSet.

Par exemple, supposons que l'on ajoute deux éléments a et b tels que (a.equals(b) && c.compare(a, b) != 0) à un TreeSet vide avec le comparateur c. La deuxième opération add renverra true (et la taille de l'ensemble d'arborescence augmentera) car a et b ne sont pas équivalents du point de vue de l'ensemble, bien que cela soit contraire à la spécification de la méthode Set.add.

Vous avez semblé indiquer que ce comportement "étrange" était inacceptable, car les éléments Comparator qui sont equals ne devraient pas figurer dans la Dummy. Mais la classe equals ne remplace pas TreeSet, il semble donc qu'une exigence supplémentaire se cache derrière.

Des questions supplémentaires ont été ajoutées aux modifications ultérieures, mais comme je l’ai mentionné ci-dessus, je pense qu’elles sont mieux traitées comme des questions séparées.UPDATE 2018-12-22.

Après avoir relu les modifications et les commentaires des questions, je pense avoir enfin compris ce que vous recherchiez. Vous voulez un comparateur sur tout objet fournissant un ordre principal basé sur une fonction de valeur int pouvant générer des valeurs en double pour des objets inégaux (comme déterminé par la méthode Dummy de l'objet). Par conséquent, un ordre secondaire est nécessaire pour fournir un ordre total sur tous les objets inégaux, mais renvoie 0 pour les objets qui sont equals. Cela implique que le comparateur doit être cohérent avec égal.

Le Ordering.arbitrary de Guava se rapproche du fait qu'il fournit un ordre total arbitraire sur tous les objets, mais il ne renvoie zéro que pour les objets identiques (c'est-à-dire ==), mais pas pour les objets equals. C'est donc incompatible avec les égaux.

Il semble donc que vous souhaitiez un comparateur fournissant un ordre arbitraire sur des objets inégaux. Voici une fonction qui en crée une:.

static Comparator<Object> arbitraryUnequal() { Map<Object, Integer> map = new HashMap<>(); return (o1, o2) -> Integer.compare(map.computeIfAbsent(o1, x -> map.size()), map.computeIfAbsent(o2, x -> map.size())); }

equals devrait être remplacée par la commande equals et le truc de taille devrait être modifié pour utiliser un paramètre HashMap qui est incrémenté lorsque de nouvelles entrées sont ajoutées.).

Notez que la carte dans ce comparateur construit des entrées pour chaque objet différent qu'il a jamais vu. Si cela est associé à une variable ConcurrentHashMap, les objets persisteront dans la carte du comparateur même après avoir été supprimés de la AtomicInteger. Cela est nécessaire pour que, si des objets sont ajoutés ou supprimés, ils conservent un ordre cohérent dans le temps. Le Ordering.arbitrary de Guava utilise des références faibles pour permettre à des objets d'être collectés s'ils ne sont plus utilisés. Nous ne pouvons pas faire cela, car nous devons conserver l'ordre des objets non identiques mais égaux.

Vous l'utiliseriez comme ceci:.

SortedSet<Dummy> sorted = new TreeSet<>(Comparator.<Dummy>comparingInt(map::get) .thenComparing(arbitraryUnequal()));


Vous avez également demandé ce que «bien défini» signifie dans ce qui suit:

Supposons que vous utilisiez une variable TreeSet en utilisant un comparateur incohérent, tel que celui utilisant le Ordering.arbitrary de Guava présenté ci-dessus. La TreeSet fonctionnera toujours comme prévu, cohérente avec elle-même. Autrement dit, il maintiendra les objets dans un ordre total, il ne contiendra pas deux objets pour lesquels le comparateur renvoie zéro et toutes ses méthodes fonctionneront comme spécifié. Cependant, il est possible qu'il y ait un objet pour lequel TreeSet renvoie true (car calculé à l'aide du comparateur) mais pour lequel TreeSet soit false s'il est appelé avec l'objet dans l'ensemble.

Par exemple, contains est equals mais sa méthode de comparaison est incohérente avec égal à:.

> BigDecimal z = new BigDecimal("0.0") > BigDecimal zz = new BigDecimal("0.00") > z.compareTo(zz) 0 > z.equals(zz) false > TreeSet<BigDecimal> ts = new TreeSet<>() > ts.add(z) > HashSet<BigDecimal> hs = new HashSet<>(ts) > hs.equals(ts) true > ts.contains(zz) true > hs.contains(zz) false

This is what the spec means when it says things can behave "strangely". We have two sets that are equal. Yet they report different results for contains of the same object, and the TreeSet reports that it contains an object even though that object is unequal to an object in the set.

3
Stuart Marks

Voici le comparateur avec lequel je me suis retrouvé. C'est à la fois fiable et efficace en mémoire.

public static <T> Comparator<T> uniqualizer() {
    return new Comparator<T>() {
        private final Map<T, Integer> extraId = new HashMap<>();
        private int id;

        @Override
        public int compare(T o1, T o2) {
            int d = Integer.compare(o1.hashCode(), o2.hashCode());
            if (d != 0)
                return d;
            if (o1.equals(o2))
                return 0;
            d = extraId.computeIfAbsent(o1, key -> id++)
              - extraId.computeIfAbsent(o2, key -> id++);
            assert id > 0 : "ID overflow";
            assert d != 0 : "Concurrent modification";
            return d;
        }
    };
}

Il crée un ordre total sur tous les objets de la classe donnée T et permet ainsi de distinguer les objets qui ne peuvent pas être distingués par un comparateur donné en s’y attachant comme ceci:

Comparator<T> partial = ...
Comparator<T> total = partial.thenComparing(uniqualizer());

Dans l'exemple donné à la question, T est Dummy et

partial = Comparator.<Dummy>comparingInt(map::get);

Notez que vous n'avez pas besoin de spécifier le type T lorsque vous appelez uniqualizer(), le client le détermine automatiquement via l'inférence de type. Vous devez seulement vous assurer que hashCode() dans T est conforme à equals(), comme décrit dans contrat général de hashCode(). Ensuite, uniqualizer() vous donnera le comparateur (total) compatible avec equals() et vous pourrez l’utiliser dans n’importe quel code nécessitant la comparaison d’objets de type T, par exemple. lors de la création d'une TreeSet:

TreeSet<T> sorted = new TreeSet<>(total);

ou en triant une liste:

List<T> list = ...
Collections.sort(list, total);
0
John McClane