Comparateur : trier selon plusieurs critères avec comparing et thenComparing
1. Vue d’ensemble
Les problèmes de tri réels ont rarement un seul critère. Par exemple : trier les commandes par priorité, à priorité égale le montant le plus élevé d’abord, si c’est toujours égal alors les commandes plus anciennes d’abord.
Cet article vous guide pour construire de tels comparateurs en utilisant Comparator.comparing et thenComparing. Vous découvrirez aussi comment gérer les valeurs null, utiliser correctement reversed() et éviter les pièges courants avec les comparateurs.
2. Préparation des données
Nous utilisons un simple record pour une commande :
import java.time.LocalDate;
public record Order(String customer, int priority, double total, LocalDate createdAt) {
}
Et une liste d’exemple. L’ordre initial est An, Binh, Chi, Dung, Em :
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class SampleData {
public static List<Order> orders() {
return new ArrayList<>(List.of(
new Order("An", 2, 150.0, LocalDate.of(2026, 10, 1)),
new Order("Binh", 1, 300.0, LocalDate.of(2026, 10, 3)),
new Order("Chi", 2, 300.0, LocalDate.of(2026, 10, 2)),
new Order("Dung", 1, 300.0, LocalDate.of(2026, 10, 1)),
new Order("Em", 2, 150.0, LocalDate.of(2026, 9, 30))
));
}
}
Nous l’enveloppons dans new ArrayList<>(...) car List.of retourne une liste immuable. Appeler sort sur cette liste lèverait une UnsupportedOperationException.
3. Comparateur avec un seul critère avec comparing
3.1. comparing avec une method reference
Comparator.comparing accepte une fonction d’extraction de clé (key extractor). Cette fonction retourne une valeur Comparable, puis le comparateur compare ces clés :
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparing(Order::customer));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Binh, Chi, Dung, Em]
Order::customer retourne une String. String implémente déjà Comparable, donc le comparateur utilise l’ordre naturel de la chaîne.
3.2. comparingInt, comparingLong, comparingDouble
Pour les clés de types primitifs, utilisez les variantes spécialisées. Elles n’effectuent pas de boxing vers Integer ou Double à chaque comparaison :
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparingDouble(Order::total));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Em, Binh, Chi, Dung]
An et Em ont tous deux un total de 150. Elles conservent leur ordre initial car List.sort utilise TimSort, un algorithme de tri stable. Les éléments égaux selon le comparateur ne sont pas inversés.
3.3. comparing avec un comparateur pour la clé
La deuxième surcharge de comparing accepte un comparateur supplémentaire pour comparer les clés. Cela vous permet de trier dans l’ordre décroissant pour un seul critère :
orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));
Cette instruction met les commandes les plus récentes en premier. Cette syntaxe est très utile lorsque vous combinez plusieurs critères, comme vous le verrez dans la section suivante.
4. Plusieurs critères avec thenComparing
thenComparing n’est utilisé que quand le comparateur précédent retourne 0, c’est-à-dire quand deux éléments sont égaux selon les critères précédents. Revenons à la demande initiale :
priorityordre croissant (1 est la plus haute priorité).totalordre décroissant.createdAtordre croissant (les commandes plus anciennes d’abord).
Comparator<Order> byBusinessRule = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
List<Order> orders = SampleData.orders();
orders.sort(byBusinessRule);
System.out.println(orders.stream().map(Order::customer).toList());
[Dung, Binh, Chi, Em, An]
Voici comment cela fonctionne étape par étape :
- Le groupe
priority = 1comprend Binh et Dung. Les deux onttotal = 300, donc le critère de date décide. Dung (01/10) se place avant Binh (03/10). - Le groupe
priority = 2comprend An, Chi et Em. Chi a 300 donc se place en tête du groupe. An et Em ont tous deux 150, donc Em (30/09) se place avant An (01/10).
Vous remarquerez la deuxième ligne : reversed() est appelé sur un comparateur séparé pour total. Cette commande ne s’applique pas à toute la chaîne. C’est là que beaucoup de gens se trompent.
5. Le piège de reversed()
reversed() inverse entièrement le comparateur sur lequel il est appelé. Voir le code suivant :
Comparator<Order> wrong = Comparator
.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed();
List<Order> orders = SampleData.orders();
orders.sort(wrong);
System.out.println(orders.stream().map(Order::customer).toList());
[Chi, An, Em, Binh, Dung]
L’auteur voulait « priority ordre croissant, total ordre décroissant ». Le résultat est que les deux sont en ordre décroissant : le groupe priority = 2 est poussé en avant. La raison est que reversed() est appliqué au comparateur combiné des deux critères.
Il y a deux façons correctes d’écrire cela, choisissez celle qui vous semble la plus lisible :
// Approche 1 : comparateur séparé pour la clé, puis l'inverser
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed());
// Approche 2 : passer le comparateur de clé à thenComparing
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
.thenComparing(Order::total, Comparator.reverseOrder());
L’approche 2 est plus courte mais doit faire du boxing de double vers Double. Avec quelques milliers d’éléments, cette différence est négligeable. Avec des données volumineuses triées continuellement, préférez l’approche 1.
Remarque : Appeler
reversed()à la fin de la chaîne n’est correct que si vous voulez vraiment inverser tous les critères. Si vous ne voulez inverser qu’un seul critère, inversez-le directement à ce critère.
6. Gérer les valeurs null
6.1. La clé peut être null
Comparator.comparing(Customer::nickname) lèverait une NullPointerException si le nickname est null. Vous enveloppez le comparateur de clé avec nullsFirst ou nullsLast :
public record Customer(String nickname) {
}
List<Customer> customers = new ArrayList<>(List.of(
new Customer("lan"),
new Customer(null),
new Customer("An"),
new Customer("binh"),
new Customer("Cuong")
));
customers.sort(Comparator.comparing(
Customer::nickname,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
));
System.out.println(customers.stream().map(Customer::nickname).toList());
[An, binh, Cuong, lan, null]
Cet exemple utilise String.CASE_INSENSITIVE_ORDER, donc les majuscules et minuscules sont traitées de la même façon. Si vous utilisiez Comparator.naturalOrder(), le résultat serait [An, Cuong, binh, lan, null]. La raison est que l’ordre naturel de String se base sur le code Unicode, et les majuscules viennent avant les minuscules.
6.2. L’élément lui-même est null
Si l’élément lui-même dans la liste peut être null, enveloppez le comparateur sur le niveau extérieur :
Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);
Ces deux cas sont différents. Envelopper la clé ne vous protège pas des éléments null, et envelopper l’élément ne vous protège pas des clés null.
7. Erreur d’inférence de type avec lambda
Le code suivant ne compile pas :
// Erreur : cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
.thenComparing(Order::createdAt);
Le compilateur déduit le type de gauche à droite dans la chaîne d’appels de méthodes. À l’appel de comparing(...), le compilateur ne connaît pas encore la destination finale Comparator<Order>, donc le paramètre o est déduit comme Object.
Il y a trois façons de corriger cela :
Comparator.comparing(Order::customer) // method reference
Comparator.comparing((Order o) -> o.customer()) // déclarer le type du paramètre
Comparator.<Order, String>comparing(o -> o.customer()) // témoin de type
La method reference est la plus concise. Utilisez-la autant que possible.
8. Écrire des tests pour un comparateur
Le comparateur contient la logique métier, il mérite d’être testé séparément. Ajoutez les dépendances :
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Vous placez le comparateur dans un endroit fixe pour le réutiliser facilement :
import java.util.Comparator;
public final class OrderComparators {
public static final Comparator<Order> BY_BUSINESS_RULE = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
private OrderComparators() {
}
}
Puis écrivez les tests :
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderComparatorsTest {
@Test
void givenOrders_whenSortByBusinessRule_thenPriorityAscTotalDescDateAsc() {
List<Order> orders = SampleData.orders();
orders.sort(OrderComparators.BY_BUSINESS_RULE);
assertThat(orders)
.extracting(Order::customer)
.containsExactly("Dung", "Binh", "Chi", "Em", "An");
assertThat(orders).isSortedAccordingTo(OrderComparators.BY_BUSINESS_RULE);
}
@Test
void givenReversedAtEnd_whenSort_thenAllCriteriaAreReversed() {
List<Order> orders = SampleData.orders();
orders.sort(java.util.Comparator.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed());
assertThat(orders.getFirst().priority()).isEqualTo(2);
}
}
containsExactly extrait une propriété pour vérifier l’ordre exact du résultat. isSortedAccordingTo vérifie que la liste est triée selon ce comparateur exactement. Le deuxième test documente le comportement de reversed() à la fin de la chaîne, pour que les développeurs futurs ne refassent pas cette erreur. getFirst() est une nouvelle méthode de List en Java 21.
9. Erreurs courantes et quand ne pas utiliser
9.1. Comparaison par soustraction
// Ne faites pas cela
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();
La soustraction peut causer un débordement d’entier. Par exemple, Integer.MIN_VALUE - 1 donne un grand nombre positif, donc la comparaison est incorrecte. Utilisez plutôt Comparator.comparingInt(...) ou Integer.compare(a, b).
9.2. Comparateur incohérent avec equals dans TreeSet/TreeMap
TreeSet considère deux éléments comme identiques quand le comparateur retourne 0. Il n’utilise pas equals pour tester l’égalité :
Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());
System.out.println(set.size());
2
Sur cinq commandes, il n’en reste que deux, une par priority. Si vous utilisez un comparateur pour TreeSet ou TreeMap, ajoutez un dernier critère unique, comme un identifiant de commande. Ainsi le comparateur ne retourne 0 que pour les éléments vraiment identiques.
9.3. Quand ne pas utiliser la chaîne comparing
- Les critères dépendent les uns des autres. Par exemple : « les commandes VIP sont comparées par montant, les commandes régulières par date ». Écrire un comparateur lambda avec
ifsera plus clair qu’une chaînethenComparingcontournée. - Calculer la clé est coûteux. L’extracteur de clé est appelé à chaque comparaison, environ
n log nfois. Si la clé doit parser une chaîne ou appeler un service, calculez la clé une fois au préalable puis triez. - Le tri peut être effectué à la base de données. Si les données proviennent d’une requête SQL,
ORDER BYest généralement plus rapide et vous évite de charger trop de données en mémoire.
10. Conclusion
Dans cet article, vous avez appris à :
- Utiliser
comparing,comparingIntetcomparingDoublepour créer un comparateur à partir d’une clé. - Combiner plusieurs critères avec
thenComparing, et inverser correctement un seul critère avecreversed()ouComparator.reverseOrder(). - Gérer les valeurs
nullavecnullsFirstetnullsLast, au niveau de la clé comme au niveau de l’élément. - Éviter les erreurs d’inférence de type avec les lambdas, les débordements d’entier lors de soustraction et la perte d’éléments dans
TreeSet.
Placez les comparateurs métier dans des constantes bien nommées, et accompagnez-les toujours d’un test vérifiant l’ordre attendu. Quand les règles de tri changent, les tests vous le signaleront immédiatement.