如何通过多个字段比较对象

317
假设您有一些对象,这些对象具有可以进行比较的多个字段:

假设你有一些对象,它们有几个可以进行比较的字段:

public class Person {

    private String firstName;
    private String lastName;
    private String age;

    /* Constructors */

    /* Methods */

}

因此,在这个例子中,当您问:

a.compareTo(b) > 0

如果您想知道a的姓氏是否在b的前面,或者a是否比b年龄大等等,可能会让你产生这样的疑问...

最干净的方法是什么,可以使这些类型的对象之间进行多个比较而不添加不必要的混乱或开销?

  • java.lang.Comparable 接口只允许按一个字段进行比较
  • 我认为添加大量的比较方法(例如 compareByFirstName()compareByAge() 等)会很混乱。

那么最好的方法是什么呢?


5
为什么这被标记为CW?这是一个完全有效的编程问题。 - Elie
3
您知道 Comparable 接口允许按照您想要的任意多个字段进行比较吗? - DJClayworth
24个回答

538

使用Java 8:

Comparator.comparing((Person p)->p.firstName)
          .thenComparing(p->p.lastName)
          .thenComparingInt(p->p.age);

如果您有访问器方法:

Comparator.comparing(Person::getFirstName)
          .thenComparing(Person::getLastName)
          .thenComparingInt(Person::getAge);
如果一个类实现了Comparable接口,那么在compareTo方法中可以使用这样的比较器:
@Override
public int compareTo(Person o){
    return Comparator.comparing(Person::getFirstName)
              .thenComparing(Person::getLastName)
              .thenComparingInt(Person::getAge)
              .compare(this, o);
}

14
特别是对于链式比较器,(Person p) 这个参数非常重要。 - membersound
8
在比较大量对象(例如排序)时,它的效率如何?在每次调用中是否需要创建新的 "Comparator" 实例? - jjurm
5
当我比较一个字段时,如果其中一个字段是空的(比如字符串),我会收到一个 NullPointerException。有没有办法保持这种比较格式,但又能处理空值呢? - rveach
6
.thenComparing(Person::getLastName, Comparator.nullsFirst(Comparator.naturalOrder())) - 首先选取第一个字段,然后使用比较器对其进行排序。 - gavenkoa
6
当你在compareTo方法中使用它(指Comparator)时,每次调用该方法都会创建一个新的Comparator。你可以通过将Comparator存储在私有静态常量字段中来防止这种情况发生。 - Gandalf
4
@membersound说:(Person p)不是一个类型转换。它是lambda表达式参数的类型声明。 - Stefan Steinegger

183

你应该实现 Comparable <Person> 接口。假设所有字段都不为空(为简单起见),年龄是一个整数,比较顺序是先按名字排序,再按姓氏排序,最后按年龄排序,那么 compareTo 方法就非常简单:

public int compareTo(Person other) {
    int i = firstName.compareTo(other.firstName);
    if (i != 0) return i;

    i = lastName.compareTo(other.lastName);
    if (i != 0) return i;

    return Integer.compare(age, other.age);
}

10
如果你实现了Comparable<Person>接口,那么方法应该是compareTo(Person p)。似乎这个答案与Comparator的compare<T o1, T o2>方法混淆了。 - Mike
6
不建议这样做。当你有多个字段时,请使用Comparator。 - indika
1
这是目前最好的解决方案,比更多的比较器更好。 - Vasile Surdu
5
@indika,我很好奇:为什么不建议这样做?在我的看法中,使用多个属性进行比较似乎完全没有问题。 - ars-longa-vita-brevis
4
如果你使用Comparable接口,那么排序逻辑必须在包含被排序对象的类内部实现,这称为自然排序。如果你使用Comparator接口,你可以在Person类外编写自定义的排序逻辑。但是,如果你只想按照Person对象的名字或姓氏进行比较,则无法使用自然排序,需要重新编写排序逻辑。 - indika

130

(摘自基于多个字段对Java对象列表进行排序的方法

代码示例请见此处的gist

使用Java 8 Lambda表达式(于2019年4月10日添加)

Java 8通过Lambda表达式优美地解决了这个问题(尽管Guava和Apache Commons可能仍然提供更多的灵活性):

Collections.sort(reportList, Comparator.comparing(Report::getReportKey)
            .thenComparing(Report::getStudentNumber)
            .thenComparing(Report::getSchool));

我借助@gaoagong的下面的答案,感谢他。

混乱且复杂:手动排序

Collections.sort(pizzas, new Comparator<Pizza>() {  
    @Override  
    public int compare(Pizza p1, Pizza p2) {  
        int sizeCmp = p1.size.compareTo(p2.size);  
        if (sizeCmp != 0) {  
            return sizeCmp;  
        }  
        int nrOfToppingsCmp = p1.nrOfToppings.compareTo(p2.nrOfToppings);  
        if (nrOfToppingsCmp != 0) {  
            return nrOfToppingsCmp;  
        }  
        return p1.name.compareTo(p2.name);  
    }  
});  

This requires a lot of typing, maintenance and is error prone. 需要大量的打字、维护和容易出错。
The reflective way: Sorting with BeanComparator 反射方式:使用BeanComparator进行排序
ComparatorChain chain = new ComparatorChain(Arrays.asList(
   new BeanComparator("size"), 
   new BeanComparator("nrOfToppings"), 
   new BeanComparator("name")));

Collections.sort(pizzas, chain);  

显然,这种方式更加简洁,但由于使用字符串而失去了对字段的直接引用(没有类型安全性、自动重构),所以错误率更高。如果字段被重命名,编译器甚至不会报告问题。此外,由于这种解决方案使用反射,排序速度要慢得多。
到达目的地的方法:使用Google Guava的ComparisonChain进行排序。
Collections.sort(pizzas, new Comparator<Pizza>() {  
    @Override  
    public int compare(Pizza p1, Pizza p2) {  
        return ComparisonChain.start().compare(p1.size, p2.size).compare(p1.nrOfToppings, p2.nrOfToppings).compare(p1.name, p2.name).result();  
        // or in case the fields can be null:  
        /* 
        return ComparisonChain.start() 
           .compare(p1.size, p2.size, Ordering.natural().nullsLast()) 
           .compare(p1.nrOfToppings, p2.nrOfToppings, Ordering.natural().nullsLast()) 
           .compare(p1.name, p2.name, Ordering.natural().nullsLast()) 
           .result(); 
        */  
    }  
});  

这样做更好,但对于最常见的用例需要一些样板代码:默认情况下,空值应该被赋予较低的值。对于空字段,您必须向Guava提供额外的指令来处理这种情况。如果您想要执行特定操作,则这是一种灵活的机制,但通常您希望使用默认情况(即1、a、b、z、null)。
使用Apache Commons CompareToBuilder进行排序
Collections.sort(pizzas, new Comparator<Pizza>() {  
    @Override  
    public int compare(Pizza p1, Pizza p2) {  
        return new CompareToBuilder().append(p1.size, p2.size).append(p1.nrOfToppings, p2.nrOfToppings).append(p1.name, p2.name).toComparison();  
    }  
});  

与Guava的ComparisonChain类似,这个库类可以轻松地按多个字段排序,并且还定义了null值的默认行为(即1、a、b、z、null)。但是,除非提供自己的Comparator,否则不能指定其他任何内容。

因此

最终取决于口味和对灵活性的需求(Guava的ComparisonChain)与简洁的代码(Apache的CompareToBuilder)。

额外的方法

我发现了一个不错的解决方案,在on CodeReview中按优先级组合了多个比较器,使用MultiComparator实现:

class MultiComparator<T> implements Comparator<T> {
    private final List<Comparator<T>> comparators;

    public MultiComparator(List<Comparator<? super T>> comparators) {
        this.comparators = comparators;
    }

    public MultiComparator(Comparator<? super T>... comparators) {
        this(Arrays.asList(comparators));
    }

    public int compare(T o1, T o2) {
        for (Comparator<T> c : comparators) {
            int result = c.compare(o1, o2);
            if (result != 0) {
                return result;
            }
        }
        return 0;
    }

    public static <T> void sort(List<T> list, Comparator<? super T>... comparators) {
        Collections.sort(list, new MultiComparator<T>(comparators));
    }
}

当然,Apache Commons Collections已经有一个此类工具了:

ComparatorUtils.chainedComparator(comparatorCollection)

Collections.sort(list, ComparatorUtils.chainedComparator(comparators));

90

你可以实现一个Comparator来比较两个Person对象,并且你可以检查尽可能多的字段。你可以在比较器中放入一个变量,告诉它要比较哪个字段,不过写多个比较器可能会更简单。


6
我更喜欢使用单个比较器的想法。我认为这个答案并没有错,但任何阅读它的人都应该确保查看下面Steve Kuo的答案。 - Felipe Leão
多个比较器只有在您需要不是数据本身函数的不同比较方法时才会使用 - 例如,有时您想按名称比较,其他时候按年龄比较等。要同时按多个字段进行比较,只需要一个比较器即可。 - Elie

23

@Patrick 如果需要对多个字段按顺序进行排序,可以尝试使用ComparatorChain

ComparatorChain是一个比较器,它按照顺序包装了一个或多个比较器。ComparatorChain按顺序调用每个比较器,直到:1) 任何一个比较器返回非零结果(然后返回该结果),或者2) ComparatorChain已经用完(并返回0)。这种类型的排序非常类似于SQL中的多列排序,当对List进行排序时,此类允许Java类模拟这种行为。

为了进一步促进像SQL一样的排序,列表中任何单个比较器的顺序都可以被反转。

在调用compare(Object, Object)之后调用添加新比较器或更改升序/降序排序的方法将导致UnsupportedOperationException。但是,请注意不要更改底层比较器列表或定义排序顺序的BitSet。

ComparatorChain实例未同步。该类在构造时不是线程安全的,但完成所有设置操作后,执行多个比较是线程安全的。


21

你可以考虑的另一个选项是Apache Commons。它提供了很多选择。

import org.apache.commons.lang3.builder.CompareToBuilder;

举例:

public int compare(Person a, Person b){

   return new CompareToBuilder()
     .append(a.getName(), b.getName())
     .append(a.getAddress(), b.getAddress())
     .toComparison();
}

14
import com.google.common.collect.ComparisonChain;

/**
 * @author radler
 * Class Description ...
 */
public class Attribute implements Comparable<Attribute> {

    private String type;
    private String value;

    public String getType() { return type; }
    public void setType(String type) { this.type = type; }

    public String getValue() { return value; }
    public void setValue(String value) { this.value = value; }

    @Override
    public String toString() {
        return "Attribute [type=" + type + ", value=" + value + "]";
    }

    @Override
    public int compareTo(Attribute that) {
        return ComparisonChain.start()
            .compare(this.type, that.type)
            .compare(this.value, that.value)
            .result();
    }

}

1
我非常喜欢这个策略。谢谢! - Mr. Polywhirl
最有效的方式!谢谢 - Zakaria Bouazza

13

10

如果你能使用Java 8流API,这里有一个更简洁的方法,文档详见: Lambdas and sorting

我正在寻找与C# LINQ相当的东西:

.ThenBy(...)

我发现了Java 8中Comparator的机制:

.thenComparing(...)

这里是展示该算法的代码片段。

    Comparator<Person> comparator = Comparator.comparing(person -> person.name);
    comparator = comparator.thenComparing(Comparator.comparing(person -> person.age));

请查看上面的链接,了解一种更简洁的方式以及关于Java类型推断如何使其相对于LINQ定义起来更加笨拙的解释。
以下是完整的单元测试代码,供参考:
@Test
public void testChainedSorting()
{
    // Create the collection of people:
    ArrayList<Person> people = new ArrayList<>();
    people.add(new Person("Dan", 4));
    people.add(new Person("Andi", 2));
    people.add(new Person("Bob", 42));
    people.add(new Person("Debby", 3));
    people.add(new Person("Bob", 72));
    people.add(new Person("Barry", 20));
    people.add(new Person("Cathy", 40));
    people.add(new Person("Bob", 40));
    people.add(new Person("Barry", 50));

    // Define chained comparators:
    // Great article explaining this and how to make it even neater:
    // http://blog.jooq.org/2014/01/31/java-8-friday-goodies-lambdas-and-sorting/
    Comparator<Person> comparator = Comparator.comparing(person -> person.name);
    comparator = comparator.thenComparing(Comparator.comparing(person -> person.age));

    // Sort the stream:
    Stream<Person> personStream = people.stream().sorted(comparator);

    // Make sure that the output is as expected:
    List<Person> sortedPeople = personStream.collect(Collectors.toList());
    Assert.assertEquals("Andi",  sortedPeople.get(0).name); Assert.assertEquals(2,  sortedPeople.get(0).age);
    Assert.assertEquals("Barry", sortedPeople.get(1).name); Assert.assertEquals(20, sortedPeople.get(1).age);
    Assert.assertEquals("Barry", sortedPeople.get(2).name); Assert.assertEquals(50, sortedPeople.get(2).age);
    Assert.assertEquals("Bob",   sortedPeople.get(3).name); Assert.assertEquals(40, sortedPeople.get(3).age);
    Assert.assertEquals("Bob",   sortedPeople.get(4).name); Assert.assertEquals(42, sortedPeople.get(4).age);
    Assert.assertEquals("Bob",   sortedPeople.get(5).name); Assert.assertEquals(72, sortedPeople.get(5).age);
    Assert.assertEquals("Cathy", sortedPeople.get(6).name); Assert.assertEquals(40, sortedPeople.get(6).age);
    Assert.assertEquals("Dan",   sortedPeople.get(7).name); Assert.assertEquals(4,  sortedPeople.get(7).age);
    Assert.assertEquals("Debby", sortedPeople.get(8).name); Assert.assertEquals(3,  sortedPeople.get(8).age);
    // Andi     : 2
    // Barry    : 20
    // Barry    : 50
    // Bob      : 40
    // Bob      : 42
    // Bob      : 72
    // Cathy    : 40
    // Dan      : 4
    // Debby    : 3
}

/**
 * A person in our system.
 */
public static class Person
{
    /**
     * Creates a new person.
     * @param name The name of the person.
     * @param age The age of the person.
     */
    public Person(String name, int age)
    {
        this.age = age;
        this.name = name;
    }

    /**
     * The name of the person.
     */
    public String name;

    /**
     * The age of the person.
     */
    public int age;

    @Override
    public String toString()
    {
        if (name == null) return super.toString();
        else return String.format("%s : %d", this.name, this.age);
    }
}

8
手动编写一个比较器来处理这种情况,在我看来是一个糟糕的解决方案。这种临时应急的方法有很多缺点:
  • 没有代码重用,违反了DRY原则。
  • 过多样板代码。
  • 增加错误发生的可能性。

所以,解决方案是什么呢?
首先,我们来讲一些理论。
我们用 "类型 A 支持比较" 来表示命题,即 Ord A。(从程序的角度来看,你可以将 Ord A 视为一个包含逻辑用于比较两个 A 的对象。是的,就像 Comparator 一样。)
现在,如果有 Ord AOrd B,那么它们的组合 (A, B) 也应支持比较。即 Ord (A, B)。如果有 Ord AOrd BOrd C,那么就有 Ord (A, B, C)
我们可以将这种论证扩展到任意元数,即: Ord A, Ord B, Ord C, ..., Ord ZOrd (A, B, C, .., Z) 我们称这个陈述为第1个语句。
复合体的比较将按照你在问题中描述的方式进行:首先尝试第一个比较,然后是下一个比较,以此类推。
这是我们解决方案的第一部分。现在是第二部分。
如果您知道Ord A,并知道如何将B转换为A(称该转换函数为f),那么您也可以拥有Ord B。怎么做?当要比较两个B实例时,您首先使用f将它们转换为A,然后应用Ord A
在这里,我们将变换B → A映射到Ord A → Ord B。这称为逆变映射(或简称comap)。 Ord A,(B→A)comapOrd B 让我们把这个陈述称作2。
现在让我们将这个应用到你的例子中。
你有一个名为“Person”的数据类型,包含三个String类型的字段。
我们知道Ord String。根据语句1,Ord (String, String, String)
我们可以很容易地编写从Person(String, String, String)的函数。(只需返回三个字段。)由于我们知道Ord (String, String, String)Person → (String, String, String),根据语句2,我们可以使用comap来获得Ord Person
证毕。

如何实现所有这些概念?

好消息是你不需要。已经存在一个库,它实现了本文中描述的所有想法。(如果你好奇这些是如何实现的,可以看看内部结构。)

使用它后代码将如下所示:

Ord<Person> personOrd = 
 p3Ord(stringOrd, stringOrd, stringOrd).comap(
   new F<Person, P3<String, String, String>>() {
     public P3<String, String, String> f(Person x) {
       return p(x.getFirstName(), x.getLastname(), x.getAge());
     }
   }
 );

解释:

  • stringOrd是一个Ord<String>类型的对象。这对应于我们最初的“支持比较”命题。
  • p3Ord是一个方法,接受Ord<A>Ord<B>Ord<C>,并返回Ord<P3<A, B, C>>。这对应于语句1。(P3代表三个元素的积。积是复合的代数术语。)
  • comap对应于嗯,comap
  • F<A, B>表示一个转换函数A → B
  • p是一个用于创建产品的工厂方法。
  • 整个表达式对应于语句2。
希望有所帮助。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接